From 0c61354a5a27f4680bae22638ec67e0dce34b6a6 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Mon, 7 Sep 2026 15:31:28 +0900 Subject: [PATCH] stable --- docs/README.md | 6 ++-- docs/VIEWER_FIXES.md | 18 +++++++++++ index.html | 69 +++++++++++++++++------------------------ tests/browser_smoke.mjs | 34 +++++++++++++++++++- tests/regression.mjs | 23 ++++++++++++-- 5 files changed, 104 insertions(+), 46 deletions(-) diff --git a/docs/README.md b/docs/README.md index 88ee448..d0ef08d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ - 描画は高速方式に統一。画素間隔に応じてDirect f32または参照軌道を使うFAST perturbationへ自動で切り替える。旧「正確」の共有URLもこの方式で開く。 - パン/ズーム、配色変更、URLによる表示位置の共有、PNG出力に対応。 - 数値未確定画素の補修と未脱出画素の継続計算を終えてから、完成画像を履歴へ保存する。 -- 計算中は現在の視点の暫定画像を表示する。反復上限は「自動」または指定回数を選べる。未脱出を集合内の証明とは扱わない。 +- 計算中は現在の視点の暫定画像を表示する。「画質」は高速・標準・高画質の3段階で、解像度と反復上限をまとめて変更する。未脱出を集合内の証明とは扱わない。 - 画面と同じ解像度・1 sampleのPNGは、補修済みの完成画像をそのまま保存する。 - 色相変化は表示済みの数値結果を使い、計算中も色を更新する。停止ボタンまたは色相位置の手動操作で停止する。 - 彩色は「ピンクと黒」を追加し、「桃翠」「ネオン」「紅碧」「深海」を削除した。彩色の変更は計算中でも表示済みの数値結果を使い、数値再計算を起動しない。 @@ -33,10 +33,10 @@ node tests/regression.mjs node tests/browser_smoke.mjs 9333 all ``` -`all` は `reset`、`fast`、`mid`、`legacy`、`axis`、`black`、`kernels`、`cancel`、`budget`、`ui`、`palette` に置き換えて単独実行できる。描画6ケース(旧方式指定の移行を含む)、キューの被覆、独立高精度サンプル、計算中のキャンセル、指定反復予算、PNGの復号、色相変化、時間表示、彩色変更時の数値再計算の抑止を確認する。 +`all` は `reset`、`fast`、`mid`、`legacy`、`axis`、`black`、`kernels`、`cancel`、`budget`、`ui`、`palette`、`pan`、`quality` に置き換えて単独実行できる。描画6ケース(旧方式指定の移行を含む)、キューの被覆、独立高精度サンプル、計算中のキャンセル、指定反復予算、PNGの復号、色相変化、時間表示、彩色変更時の数値再計算の抑止、画質ごとの解像度と有限反復での完了を確認する。 起動時は拡張機能と同期を無効にし、専用プロファイルのタブを1つだけにする。テストはそのタブを再利用し、最後に `about:blank` へ戻す。終了後は検証用ブラウザーを閉じる。 測定が必要な場合だけ `node tests/browser_smoke.mjs 9333 bench` を実行する。初期表示・Seahorse FASTを各1回ウォームアップ+3回測定する。通常の回帰確認ではベンチマークを繰り返さない。 -検証APIと詳細計測は `index.html?test` でのみ有効。通常起動では監査計算を自動実行しない。Strict設定も比較テストを起動しない。 +検証APIと詳細計測は `index.html?test` でのみ有効。通常起動では監査計算を自動実行しない。 diff --git a/docs/VIEWER_FIXES.md b/docs/VIEWER_FIXES.md index 1676e4e..7ddeee8 100644 --- a/docs/VIEWER_FIXES.md +++ b/docs/VIEWER_FIXES.md @@ -59,3 +59,21 @@ CPU回帰と実GPUの `palette`・`ui` が通過。1100×720の実画面で新 320×240で同一のパンを実マウスイベントから実行した結果、完了までオフ約343 ms、オン約352 msだった。彩色へ意図的に100 msの遅延を加えた検査でも約677 msで完了し、3条件の数値ハッシュが一致した。各1回の測定であり、機器・座標に依存する。再現検査は `node tests/browser_smoke.mjs 9333 pan`。 プラムの暗部も再調整し、未脱出・内点は `#0e0a13`、グラデーションの最暗部は `#140e1a` とした。ローズと淡いピンクの色は維持する。 + +## 画質設定への統合 + +負荷設定と反復上限を「画質」へ統合した。画面のデバイスピクセル数を基準に、次の解像度倍率と反復上限を適用する。 + +| 画質 | 縦横の倍率 | 画素数上限 | 反復上限 | +| --- | ---: | ---: | ---: | +| 高速 | 0.75倍 | 524,288 | 4,096 | +| 標準 | 1倍 | 1,048,576 | 16,384 | +| 高画質 | 1.5倍 | 2,359,296 | 32,768 | + +高画質は標準の最大2.25倍の画素を実際に計算する。旧「精細」の近隣画素を平均する処理は使わない。端末メモリーが4 GB以下の場合は画素数上限を半減し、GPUのバッファ・テクスチャ制限も適用する。 + +旧「精細」「保守的」の補修回数増加とStrict経路を通常操作から外し、補修方針を標準へ統一した。反復上限へ到達した未脱出画素を有限反復の結果として完了できるようにし、長い自動継続を避ける。到達不足や数値故障を完了へ偽装することはない。高画質は計算量が増えるため、標準より完了に時間がかかる。 + +画質は共有URLの `q` とローカル設定へ保存する。旧「精細」「保守的」は高画質へ、旧反復上限の指定は対応する有限予算の画質へ移行する。画質変更時は進行中の計算をキャンセルし、新しい解像度で描画する。彩色だけの変更では数値計算をやり直さない。 + +CPU回帰と実GPUの `quality` 検査で、初期表示・Seahorse深拡大・中間倍率の3視点×3画質が完了した。320×240の表示領域で描画サイズは高速240×180、標準320×240、高画質480×360。9ケースとも数値失敗0、暫定状態の解消、上限到達、PNG一致を確認した。設定復元と計算中の画質切り替えも通過。これは検査した視点での結果であり、任意の座標での完了時間を保証するものではない。 diff --git a/index.html b/index.html index a6029ac..e03b336 100644 --- a/index.html +++ b/index.html @@ -32,8 +32,7 @@ dialog{width:min(430px,calc(100vw - 24px));border:1px solid var(--line);border-r
-
-
+
0.0080
.18
@@ -1006,7 +1005,14 @@ const UNKNOWN_REASON_NAMES=['none','error-bound','escape-uncertain','reference-e const NUMERIC_PARAM_BYTES=96,UNRESOLVED_BYTES=32,SPARSE_QUEUE_STATS_BYTES=32,SPARSE_INDIRECT_BYTES=16,DEEP_BUCKET_STATE_BYTES=96; const PIXEL_FRONTIER_PRODUCTION=true,PIXEL_FRONTIER_WORK=4000000,PIXEL_FRONTIER_MAX_ITER=150000; const PIXEL_FRONTIER_ENABLED=PIXEL_FRONTIER_PRODUCTION; -const state={bits:INITIAL_BITS,re:0n,im:0n,span:0n,baseIter:350,adaptive:true,continuationBudget:0,hq:false,processMode:'standard',renderMode:'fast',palette:0,cycle:.008,shift:.18,colorAuto:false,colorRevision:0,token:0,rendering:false,recoloring:false,recolorPending:false,dirty:true,lastRender:0,renderClock:null,lastEngine:'起動中',drawState:'REPROJECTED',frameView:null,fieldView:null,pointerActive:false,wheelActive:false,effectiveDpr:1,screenPixelBudget:0,unresolved:0,unknownReasons:null,correctionPasses:0,correctedPixels:0,multiRefPasses:0,multiRefRecovered:0,frontierRounds:0,frontierPixels:0,frontierEscaped:0,frontierIter:0,frontierMinimumIter:0,frontierConverged:false,backendDecision:null,gpuError:'',gpuInitFailed:false,gpuUnavailable:false,lastInteraction:performance.now(),focusX:.5,focusY:.5,uiHidden:false,qualityStage:'idle',qualityIter:0,qualityFinalIter:0,previewMs:0,refineMs:0,refinePending:false,temporalFill:false,numericRecoveryPending:false,numericRecoveryRunning:false,gpuStage:'',gpuTileRows:0}; +const QUALITY_PRESETS=Object.freeze({ + fast:{scale:.75,pixels:524288,iterations:4096}, + standard:{scale:1,pixels:1048576,iterations:16384}, + high:{scale:1.5,pixels:2359296,iterations:32768} +}); +function normalizeQuality(value){return value==='fast'||value==='power'?'fast':value==='high'||value==='fine'||value==='validate'?'high':'standard'} +function applyQuality(value){state.quality=normalizeQuality(value);state.continuationBudget=QUALITY_PRESETS[state.quality].iterations;state.hq=false} +const state={bits:INITIAL_BITS,re:0n,im:0n,span:0n,baseIter:350,adaptive:true,continuationBudget:16384,hq:false,quality:'standard',renderMode:'fast',palette:0,cycle:.008,shift:.18,colorAuto:false,colorRevision:0,token:0,rendering:false,recoloring:false,recolorPending:false,dirty:true,lastRender:0,renderClock:null,lastEngine:'起動中',drawState:'REPROJECTED',frameView:null,fieldView:null,pointerActive:false,wheelActive:false,effectiveDpr:1,screenPixelBudget:0,unresolved:0,unknownReasons:null,correctionPasses:0,correctedPixels:0,multiRefPasses:0,multiRefRecovered:0,frontierRounds:0,frontierPixels:0,frontierEscaped:0,frontierIter:0,frontierMinimumIter:0,frontierConverged:false,backendDecision:null,gpuError:'',gpuInitFailed:false,gpuUnavailable:false,lastInteraction:performance.now(),focusX:.5,focusY:.5,uiHidden:false,qualityStage:'idle',qualityIter:0,qualityFinalIter:0,previewMs:0,refineMs:0,refinePending:false,temporalFill:false,numericRecoveryPending:false,numericRecoveryRunning:false,gpuStage:'',gpuTileRows:0}; let renderer=null,rendererInitPromise=null,raf=0,settleTimer=0,refineTimer=0,lastWrittenHash='',navigationHash=''; const viewHistory=[];let viewHistoryIndex=-1; const TEST_MODE=new URLSearchParams(location.search).has('test'); @@ -1046,29 +1052,17 @@ function fastNeedsExtended(snap,w){return !(directPixelRatio(snap,w)>=4)} function chooseBackend(snap=snapshot(),w=canvas.width){const fastExtended=fastNeedsExtended(snap,w);return{backend:fastExtended?'fast-extended':'direct',deep:false,fastExtended,probe:false,reason:fastExtended?'auto-fast-extended':'auto-direct'}} function pixelPrecisionBits(snap=snapshot(),w=canvas.width,guard=32){const pixelLog2=log2FixedAt(snap.span,snap.bits)-Math.log2(Math.max(1,w)),need=Math.ceil(Math.max(0,-pixelLog2))+Math.max(16,guard|0);return Math.max(96,Math.min(snap.bits+64,need))} function deepNeeded(){return false} -function currentViewSpec(){return{bits:state.bits,re:state.re,im:state.im,span:state.span,palette:state.palette,cycle:state.cycle,shift:state.shift,baseIter:state.baseIter,adaptive:state.adaptive,continuationBudget:state.continuationBudget,renderMode:state.renderMode}} -function viewSpecKey(v){return[v.bits,v.re,v.im,v.span,v.palette,v.cycle,v.shift,v.baseIter,v.adaptive,v.continuationBudget||0].join(':')} +function currentViewSpec(){return{bits:state.bits,re:state.re,im:state.im,span:state.span,palette:state.palette,cycle:state.cycle,shift:state.shift,baseIter:state.baseIter,adaptive:state.adaptive,continuationBudget:state.continuationBudget,quality:state.quality,renderMode:state.renderMode}} +function viewSpecKey(v){return[v.bits,v.re,v.im,v.span,v.palette,v.cycle,v.shift,v.baseIter,v.adaptive,v.continuationBudget||0,v.quality||'standard'].join(':')} function recordView(){const v=currentViewSpec(),k=viewSpecKey(v);if(viewHistoryIndex>=0&&viewSpecKey(viewHistory[viewHistoryIndex])===k)return;viewHistory.splice(viewHistoryIndex+1);viewHistory.push(v);if(viewHistory.length>80)viewHistory.shift();viewHistoryIndex=viewHistory.length-1;syncHistoryButtons()} -function numericViewKey(){return[state.bits,state.re,state.im,state.span,state.baseIter,state.adaptive,state.continuationBudget].join(':')} -function restoreView(v){if(!v)return;const before=numericViewKey();Object.assign(state,{bits:v.bits,re:v.re,im:v.im,span:v.span,palette:VALID_PALETTE_IDS.has(v.palette)?v.palette:0,cycle:v.cycle,shift:v.shift,baseIter:v.baseIter,adaptive:v.adaptive,continuationBudget:v.continuationBudget||0,renderMode:'fast'});ensurePrecision();syncControls();saveHash(false);if(before===numericViewKey())requestRecolor();else markDirty()} +function numericViewKey(){return[state.bits,state.re,state.im,state.span,state.baseIter,state.adaptive,state.continuationBudget,state.quality].join(':')} +function restoreView(v){if(!v)return;const before=numericViewKey();Object.assign(state,{bits:v.bits,re:v.re,im:v.im,span:v.span,palette:VALID_PALETTE_IDS.has(v.palette)?v.palette:0,cycle:v.cycle,shift:v.shift,baseIter:v.baseIter,adaptive:v.adaptive,continuationBudget:v.continuationBudget||0,renderMode:'fast'});applyQuality(v.quality||'standard');ensurePrecision();resize();syncControls();saveHash(false);if(before===numericViewKey())requestRecolor();else markDirty()} // ── iteration / quality policy ─────────────────────────────────────────── const ADAPTIVE_ITER_QUANTUM=64; function adaptiveIterationBonus(screenBits,q,quantum=ADAPTIVE_ITER_QUANTUM){const raw=48*Math.max(0,Number(q)||0)*Math.max(0,Number(screenBits)||0);return raw>0?Math.ceil(raw/quantum)*quantum:0} -function maxIter(){if(!state.adaptive)return Math.min(state.baseIter,state.continuationBudget||state.baseIter);const z=zoomExp(),w=Math.max(1,canvas.width||innerWidth||1),screenBits=Math.max(0,Math.log2(w)+z*Math.LOG2E*Math.LN10),q=state.processMode==='power'?.72:state.processMode==='fine'?1.25:state.processMode==='validate'?1.5:1,bonus=adaptiveIterationBonus(screenBits,q);return Math.min(state.continuationBudget||12000,12000,Math.max(state.baseIter,state.baseIter+bonus))} -function previewIter(finalIter=maxIter()){ - const z=Math.max(0,zoomExp()),m=state.processMode==='power'?.72:state.processMode==='fine'?1.28:state.processMode==='validate'?1.55:1; - // The old fixed 1100-iteration ceiling starved deep previews: around 10^12, - // finalIter is ~2700 while many visible boundary pixels need >1100 steps. - // Make the first pass track final quality as zoom deepens, with a mode-aware - // ceiling so responsiveness is still bounded on extreme views. - const legacy=Math.round(m*(420+24*z)); - const fractionBase=Math.min(.84,.48+.03*z); - const fraction=state.processMode==='power'?Math.min(.68,fractionBase*.82):state.processMode==='fine'?Math.min(.90,fractionBase+.04):state.processMode==='validate'?Math.min(.94,fractionBase+.08):fractionBase; - const deepFloor=Math.round(finalIter*fraction); - const cap=state.processMode==='power'?1800:state.processMode==='fine'?4800:state.processMode==='validate'?6000:3600; - return Math.min(finalIter,Math.max(state.baseIter,Math.min(cap,Math.max(legacy,deepFloor)))); -} +function maxIter(){if(!state.adaptive)return Math.min(state.baseIter,state.continuationBudget||state.baseIter);const z=zoomExp(),w=Math.max(1,canvas.width||innerWidth||1),screenBits=Math.max(0,Math.log2(w)+z*Math.LOG2E*Math.LN10),bonus=adaptiveIterationBonus(screenBits,1);return Math.min(state.continuationBudget||12000,12000,Math.max(state.baseIter,state.baseIter+bonus))} +function previewIter(finalIter=maxIter()){return Math.min(finalIter,Math.max(state.baseIter,Math.min(1800,Math.round(finalIter*.6))))} function canUseStableReprojection(){ if(!renderer?.historyReady||!state.frameView)return false; // Covers normal wheel/pinch, one 2x toolbar zoom, and ordinary pans while @@ -1086,12 +1080,7 @@ function qualityStages(finalIter=maxIter()){ const p=previewIter(finalIter); return finalIter>p?[p,finalIter]:[p]; } -function refinementDelay(stageIndex){ - // Full-quality latency matters more than leaving a long idle gap after the - // first preview. Keep a short interaction settling window only. - if(stageIndex<=1)return state.processMode==='validate'?300:state.processMode==='fine'?240:state.processMode==='power'?420:160; - return state.processMode==='validate'?240:state.processMode==='fine'?200:360; -} +function refinementDelay(){return 100} function sameViewSnapshot(a,b){return !!a&&!!b&&a.bits===b.bits&&a.re===b.re&&a.im===b.im&&a.span===b.span} function canUseRealAxisSymmetry(snap){return !!snap&&snap.im===0n} function reprojectionSafe(t,maxZoom=1.30,maxShift=.18){const s=Math.abs(Number(t?.scaleX));if(!Number.isFinite(s)||s<=0)return false;const z=Math.max(s,1/s),shift=Math.hypot(Number(t?.offsetX)||0,Number(t?.offsetY)||0);return z<=maxZoom&&shift<=maxShift} @@ -1101,8 +1090,8 @@ function sliderToCycle(v){const t=Math.max(0,Math.min(CYCLE_SLIDER_MAX,Number(v) function fract01(x){return x-Math.floor(x)} function colorStyleSnapshot(){return{palette:state.palette,cycle:state.cycle,shift:state.shift,hq:state.hq}} function colorStyleKey(style=colorStyleSnapshot()){return[style.palette,style.cycle,style.shift,style.hq?1:0].join(':')} -function pixelBudget(){const low=Number(navigator.deviceMemory||8)<=4,small=matchMedia('(max-width:700px)').matches;if(!navigator.gpu||state.gpuUnavailable)return 262144;if(state.processMode==='power')return 524288;if(state.processMode==='fine')return(low||small?1572864:3145728);if(state.processMode==='validate')return(low||small?1048576:2097152);return(low||small?786432:1572864)} -function resize(){const cssW=Math.max(1,innerWidth),cssH=Math.max(1,innerHeight),budget=pixelBudget(),native=Math.max(1,devicePixelRatio||1),bd=Math.sqrt(budget/(cssW*cssH));let dpr=Math.max(Math.min(1,64/Math.max(cssW,cssH)),Math.min(native,bd));if(renderer){const md=Math.max(2,renderer.adapterLimits.maxTextureDimension2D||8192);dpr=Math.min(dpr,md/cssW,md/cssH)}const w=Math.max(2,Math.round(cssW*dpr)),h=Math.max(2,Math.round(cssH*dpr));state.effectiveDpr=dpr;state.screenPixelBudget=budget;if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;if(renderer)renderer.configure();markDirty(false)}} +function pixelBudget(){const preset=QUALITY_PRESETS[state.quality],low=Number(navigator.deviceMemory||8)<=4,limit=renderer?.device?.limits?.maxStorageBufferBindingSize||134217728;return Math.max(4096,Math.min(Math.floor(preset.pixels*(low?.5:1)),Math.floor(limit/40)))} +function resize(){const cssW=Math.max(1,innerWidth),cssH=Math.max(1,innerHeight),budget=pixelBudget(),native=Math.max(1,devicePixelRatio||1),bd=Math.sqrt(budget/(cssW*cssH));let dpr=Math.max(Math.min(1,64/Math.max(cssW,cssH)),Math.min(native*QUALITY_PRESETS[state.quality].scale,bd));if(renderer){const md=Math.max(2,renderer.adapterLimits.maxTextureDimension2D||8192);dpr=Math.min(dpr,md/cssW,md/cssH)}const w=Math.max(2,Math.round(cssW*dpr)),h=Math.max(2,Math.round(cssH*dpr));state.effectiveDpr=dpr;state.screenPixelBudget=budget;if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;if(renderer)renderer.configure();markDirty(false)}} // ── high precision reference worker ───────────────────────────────────── function referenceWorkerSource(){return String.raw` @@ -1685,7 +1674,7 @@ class WebGpuRenderer{ function numericalFailureCount(stats){const r=stats&&stats.reasons||{};return (r.errorBound||0)+(r.escapeUncertain||0)+(r.referenceEnd||0)+(r.rebaseGap||0)+(r.range||0)} // A broad failure is cheaper to normalize once with the full-frame Deep pass; // sparse/moderate failures go directly to local references. -function tileRecoveryConfig(){const z=zoomExp(),deep=z>=32;if(state.processMode==='power')return{size:deep?224:320,maxRefs:4,minFailed:deep?4:48};if(state.processMode==='fine')return{size:deep?128:192,maxRefs:20,minFailed:deep?1:8};if(state.processMode==='validate')return{size:deep?112:160,maxRefs:32,minFailed:1};return{size:deep?160:224,maxRefs:12,minFailed:deep?1:12}} +function tileRecoveryConfig(){const deep=zoomExp()>=32;return{size:deep?160:224,maxRefs:12,minFailed:deep?1:12}} function isOperationLimitMeta(m){return ((m>>>28)&3)===FIELD_UNKNOWN&&((m>>>REASON_SHIFT)&255)===6} function operationLimitIndices(meta){const out=[];for(let i=0;i>>28)&3)!==FIELD_UNKNOWN)continue;const reason=(meta[i]>>>REASON_SHIFT)&255;if(reason>=1&&reason<=5)out.push(i)}return Uint32Array.from(out)} @@ -1797,7 +1786,7 @@ async function refinePixelFrontier(r,snap,initialIter,token,initialStats){ // the candidate list from the already-published metadata and restart once // with a newly selected long reference; no pixel is silently discarded. if(ctx.refLen0){r.setDeepContext(ctx);const corrected=await r.correctUnknownFrame(snap,target,token,refPixel);if(!corrected||token!==state.token)return null;stats=await r.readUnresolvedStats();if(token!==state.token)return null}if(numericalFailureCount(stats)>512){const beforeLocal=numericalFailureCount(stats),mr=await recoverNumericalUnknownTiles(r,snap,target,token,stats);if(!mr||token!==state.token)return null;stats=mr.stats;if(numericalFailureCount(stats)>=beforeLocal)state.multiRefRecovered=0}if(numericalFailureCount(stats)>0){const pf=await recoverResidualPrecision(r,snap,target,token,stats);if(!pf||token!==state.token)return null;stats=pf.stats}const after=await r.readMetaAll();if(token!==state.token)return null;const advance=pixelEscapeAdvance(previousMeta,after,canvas.width,canvas.height);rounds++;escaped+=advance.escaped;iter=target;stableRounds=advance.pixelStable?stableRounds+1:0;previousMeta=after;state.frontierRounds=rounds;state.frontierEscaped=escaped;state.frontierIter=iter; if(!await presentProvisional(r,snap,iter,token))return null; // A quiet pair of doubling rounds is only meaningful after every remaining @@ -1993,10 +1982,10 @@ canvas.addEventListener('pointermove',e=>{if(!pts.has(e.pointerId))return;update function endPointer(e){pts.delete(e.pointerId);pinch=0;if(pts.size)return;clearTimeout(settleTimer);settleTimer=setTimeout(()=>{state.pointerActive=false;recordView();saveHash(false);markDirty()},90)}canvas.addEventListener('pointerup',endPointer);canvas.addEventListener('pointercancel',endPointer); // ── URL / controls ─────────────────────────────────────────────────────── -function saveHash(push){const p=new URLSearchParams();p.set('v',String(VERSION));p.set('b',String(state.bits));p.set('re',state.re.toString());p.set('im',state.im.toString());p.set('sp',state.span.toString());p.set('pal',String(state.palette));p.set('cy',String(state.cycle));p.set('sh',String(state.shift));p.set('it',String(state.baseIter));p.set('ad',state.adaptive?'1':'0');if(state.continuationBudget)p.set('cb',String(state.continuationBudget));p.set('rm',state.renderMode);const h='#'+p.toString();lastWrittenHash=h;try{push?history.pushState(null,'',h):history.replaceState(null,'',h)}catch{location.hash=h}} -function loadHash(){const p=new URLSearchParams(location.hash.slice(1));if(!p.has('b'))return false;try{const b=Number(p.get('b')),re=BigInt(p.get('re')),im=BigInt(p.get('im')),sp=BigInt(p.get('sp'));if(!Number.isInteger(b)||b<64||sp<=0n)return false;state.bits=b;state.re=re;state.im=im;state.span=sp;if(p.has('pal')){const pal=Math.max(0,Math.min(PALETTE_MAX,Number(p.get('pal'))|0));state.palette=VALID_PALETTE_IDS.has(pal)?pal:0;}if(p.has('cy'))state.cycle=Math.max(CYCLE_MIN,Math.min(CYCLE_MAX,Number(p.get('cy'))||.008));if(p.has('sh'))state.shift=Math.max(0,Math.min(1,Number(p.get('sh'))||0));if(p.has('it'))state.baseIter=Math.max(100,Math.min(2500,Number(p.get('it'))||350));if(p.has('ad'))state.adaptive=p.get('ad')!=='0';state.continuationBudget=Math.max(0,Math.min(150000,Math.floor(Number(p.get('cb'))||0)));state.renderMode='fast';ensurePrecision();return true}catch{return false}} +function saveHash(push){const p=new URLSearchParams();p.set('v',String(VERSION));p.set('b',String(state.bits));p.set('re',state.re.toString());p.set('im',state.im.toString());p.set('sp',state.span.toString());p.set('pal',String(state.palette));p.set('cy',String(state.cycle));p.set('sh',String(state.shift));p.set('it',String(state.baseIter));p.set('ad',state.adaptive?'1':'0');p.set('q',state.quality);p.set('rm',state.renderMode);const h='#'+p.toString();lastWrittenHash=h;try{push?history.pushState(null,'',h):history.replaceState(null,'',h)}catch{location.hash=h}} +function loadHash(){const p=new URLSearchParams(location.hash.slice(1));if(!p.has('b'))return false;try{const b=Number(p.get('b')),re=BigInt(p.get('re')),im=BigInt(p.get('im')),sp=BigInt(p.get('sp'));if(!Number.isInteger(b)||b<64||sp<=0n)return false;state.bits=b;state.re=re;state.im=im;state.span=sp;if(p.has('pal')){const pal=Math.max(0,Math.min(PALETTE_MAX,Number(p.get('pal'))|0));state.palette=VALID_PALETTE_IDS.has(pal)?pal:0;}if(p.has('cy'))state.cycle=Math.max(CYCLE_MIN,Math.min(CYCLE_MAX,Number(p.get('cy'))||.008));if(p.has('sh'))state.shift=Math.max(0,Math.min(1,Number(p.get('sh'))||0));if(p.has('it'))state.baseIter=Math.max(100,Math.min(2500,Number(p.get('it'))||350));if(p.has('ad'))state.adaptive=p.get('ad')!=='0';applyQuality(p.has('q')?p.get('q'):p.has('cb')&&Number(p.get('cb'))>0?(Number(p.get('cb'))<=4096?'fast':Number(p.get('cb'))<=16384?'standard':'high'):state.quality);state.renderMode='fast';ensurePrecision();return true}catch{return false}} function syncHistoryButtons(){} -function syncControls(){$('#continuationBudget').value=String(state.continuationBudget);$('#processMode').value=state.processMode;$('#palette').value=String(state.palette);$('#cycle').value=String(cycleToSlider(state.cycle));$('#cycleO').textContent=state.cycle.toFixed(4);$('#shift').value=String(state.shift);$('#shiftO').textContent=state.shift.toFixed(2);syncColorAutoButton()} +function syncControls(){$('#quality').value=state.quality;$('#palette').value=String(state.palette);$('#cycle').value=String(cycleToSlider(state.cycle));$('#cycleO').textContent=state.cycle.toFixed(4);$('#shift').value=String(state.shift);$('#shiftO').textContent=state.shift.toFixed(2);syncColorAutoButton()} function toast(s){const e=$('#toast');e.textContent=s;e.classList.add('show');setTimeout(()=>e.classList.remove('show'),1500)} function applyUi(){document.body.classList.toggle('ui-hidden',state.uiHidden);$('#uiToggle').textContent=state.uiHidden?'UI+':'UI−';$('#uiToggle').setAttribute('aria-expanded',state.uiHidden?'false':'true')} $('#uiToggle').onclick=()=>{state.uiHidden=!state.uiHidden;try{localStorage.setItem('mandelbrot.uiHidden',state.uiHidden?'1':'0')}catch{}applyUi()}; @@ -2020,10 +2009,10 @@ function applyCycleControl(e){state.cycle=sliderToCycle(e.target.value);$('#cycl function applyShiftControl(e){if(state.colorAuto)stopColorAuto();state.shift=Math.max(0,Math.min(1,Number(e.target.value)||0));$('#shiftO').textContent=state.shift.toFixed(2);requestRecolor()} $('#cycle').addEventListener('input',applyCycleControl);$('#cycle').addEventListener('change',()=>saveHash(false)); $('#shift').addEventListener('input',applyShiftControl);$('#shift').addEventListener('change',()=>saveHash(false)); -$('#continuationBudget').onchange=e=>{state.continuationBudget=Math.max(0,Math.min(150000,Number(e.target.value)||0));saveHash(false);recordView();markDirty()}; -$('#processMode').onchange=e=>{state.processMode=/^(power|standard|fine|validate)$/.test(e.target.value)?e.target.value:'standard';state.hq=state.processMode==='fine'||state.processMode==='validate';resize();markDirty();try{localStorage.setItem('mandelbrot.processMode',state.processMode)}catch{}}; +$('#quality').onchange=e=>{cancelRender();applyQuality(e.target.value);resize();saveHash(false);recordView();markDirty(false);try{localStorage.setItem('mandelbrot.quality',state.quality)}catch{}}; + addEventListener('resize',()=>{const w=canvas.width,h=canvas.height;resize();if(w!==canvas.width||h!==canvas.height)markDirty()});addEventListener('keydown',e=>{if(/^(INPUT|SELECT|TEXTAREA|BUTTON)$/.test(e.target.tagName))return;let ok=true;if(e.key==='h'||e.key==='H')$('#uiToggle').click();else if(e.key==='r'||e.key==='R')$('#reset').click();else if(e.key==='+'||e.key==='='||e.key==='Enter'&&!e.shiftKey)$('#zin').click();else if(e.key==='-'||e.key==='Enter'&&e.shiftKey)$('#zout').click();else if(e.key==='ArrowLeft')pan(innerWidth*.08,0);else if(e.key==='ArrowRight')pan(-innerWidth*.08,0);else if(e.key==='ArrowUp')pan(0,innerHeight*.08);else if(e.key==='ArrowDown')pan(0,-innerHeight*.08);else ok=false;if(ok){e.preventDefault();recordView();saveHash(false);markDirty()}}); -addEventListener('hashchange',()=>{if(location.hash===lastWrittenHash){lastWrittenHash='';return}if(location.hash===navigationHash)return;navigationHash=location.hash;setTimeout(()=>navigationHash='',0);const before=numericViewKey();if(loadHash()){syncControls();recordView();if(before===numericViewKey())requestRecolor();else markDirty()}}); +addEventListener('hashchange',()=>{if(location.hash===lastWrittenHash){lastWrittenHash='';return}if(location.hash===navigationHash)return;navigationHash=location.hash;setTimeout(()=>navigationHash='',0);const before=numericViewKey();if(loadHash()){syncControls();recordView();if(before===numericViewKey())requestRecolor();else{resize();markDirty()}}}); // ── export: GPU tiled + streaming PNG, optional GPU 2x2 supersampling ────── const exportJob={active:false,cancelled:false}; @@ -2118,7 +2107,7 @@ globalThis.__MANDEL_TEST__={ return Array.from(solved.indices,(index,j)=>({index,packed:field.meta[index],smoothed:smoothed[index],oracle:solved.meta[j],oracleSmooth:solved.smooth[j],accepted:solved.accepted[j]})); }, async ensureGpuReady(){const r=renderer||await initRenderer();return{ready:!!r,navigatorGpu:!!navigator.gpu,renderer:!!renderer,gpuInitFailed:state.gpuInitFailed,gpuUnavailable:state.gpuUnavailable,gpuError:state.gpuError}}, - async setView({re,im,span,bits,baseIter=350,adaptive=false,continuationBudget=0,processMode='standard',renderMode='fast',fixed=false}){const ready=await this.ensureGpuReady();if(!ready.ready)throw new Error('WebGPU initialization failed: '+JSON.stringify(ready));cancelRender();if(bits){state.bits=bits}else{state.bits=Math.max(256,decimalRequiredBits(re),decimalRequiredBits(im),decimalRequiredBits(span))}state.re=fixed?BigInt(re):fromDec(re);state.im=fixed?BigInt(im):fromDec(im);state.span=fixed?BigInt(span):fromDec(span);state.baseIter=baseIter;state.adaptive=adaptive;state.continuationBudget=Math.max(0,Math.min(150000,Math.floor(continuationBudget)));state.processMode=processMode;state.renderMode='fast';state.hq=false;ensurePrecision();resize();markDirty(false);const start=performance.now();while((state.dirty||state.rendering)&&performance.now()-start<120000){schedule();await new Promise(r=>setTimeout(r,20))}if(state.dirty||state.rendering)throw new Error('test render timeout');if(!renderer?.frame||!state.fieldView)throw new Error('test render completed without a GPU frame: '+JSON.stringify({gpuInitFailed:state.gpuInitFailed,gpuUnavailable:state.gpuUnavailable,gpuError:state.gpuError,drawState:state.drawState,engine:state.lastEngine}));return{width:canvas.width,height:canvas.height,state:this.state()}}, + async setView({re,im,span,bits,baseIter=350,adaptive=false,continuationBudget=0,processMode='standard',renderMode='fast',quality=null,fixed=false}){const ready=await this.ensureGpuReady();if(!ready.ready)throw new Error('WebGPU initialization failed: '+JSON.stringify(ready));cancelRender();if(bits){state.bits=bits}else{state.bits=Math.max(256,decimalRequiredBits(re),decimalRequiredBits(im),decimalRequiredBits(span))}state.re=fixed?BigInt(re):fromDec(re);state.im=fixed?BigInt(im):fromDec(im);state.span=fixed?BigInt(span):fromDec(span);state.baseIter=baseIter;state.adaptive=adaptive;state.continuationBudget=Math.max(0,Math.min(150000,Math.floor(continuationBudget)));state.quality='standard';if(quality)applyQuality(quality);state.renderMode='fast';state.hq=false;ensurePrecision();resize();markDirty(false);const start=performance.now();while((state.dirty||state.rendering)&&performance.now()-start<120000){schedule();await new Promise(r=>setTimeout(r,20))}if(state.dirty||state.rendering)throw new Error('test render timeout');if(!renderer?.frame||!state.fieldView)throw new Error('test render completed without a GPU frame: '+JSON.stringify({gpuInitFailed:state.gpuInitFailed,gpuUnavailable:state.gpuUnavailable,gpuError:state.gpuError,drawState:state.drawState,engine:state.lastEngine}));return{width:canvas.width,height:canvas.height,state:this.state()}}, async sampleMeta(points){if(!renderer||!renderer.frame)throw new Error('GPU field unavailable');const idx=points.map(([x,y])=>y*renderer.frame.w+x);const m=await renderer.readMeta(idx);return Array.from(m)}, async waitForPrimary({timeout=120000}={}){await waitForTestState(()=>!state.dirty&&!state.rendering&&!!state.fieldView,'primary',timeout);return this.state()}, async waitForFinal({timeout=120000}={}){await waitForTestState(()=>!state.dirty&&!state.rendering&&!state.refinePending&&state.qualityStage==='final'&&state.fieldView?.complete===true&&state.renderClock?.status==='complete','final',timeout);return this.state()}, @@ -2126,7 +2115,7 @@ globalThis.__MANDEL_TEST__={ async fieldHashes(){if(!renderer?.frame)throw new Error('WebGPU field unavailable');const field=await renderer.readFieldAll();return{pixels:field.meta.length,metaSha256:await sha256View(field.meta),smoothSha256:await sha256View(field.smoothBits)}}, gpuDiagnostics:()=>renderer?{adapterInfo:{...renderer.adapterInfo},adapterLimits:{...renderer.adapterLimits},compilation:renderer.compilation.map(x=>({...x})),uncapturedErrors:[...renderer.uncapturedErrors],lossReason:renderer.lossReason||'',activeGpuStage:renderer.activeGpuStage,lastCompletedGpuStage:renderer.lastCompletedGpuStage}:null, stabilityPolicy:()=>({adaptiveIterationQuantum:ADAPTIVE_ITER_QUANTUM,pixelFrontierProduction:PIXEL_FRONTIER_PRODUCTION,pixelFrontierEnabled:PIXEL_FRONTIER_ENABLED,pixelFrontierWorkLimit:PIXEL_FRONTIER_WORK,pixelFrontierMaxIter:PIXEL_FRONTIER_MAX_ITER,pixelFrontierMinimumIter:pixelFrontierIterationFloor(snapshot(),canvas.width,maxIter()),stableHistoryReady:!!renderer?.historyReady,deepCorrectionDispatchWork:PIXEL_FRONTIER_WORK}), - state:()=>{const snap=snapshot(),se=spanMantExp(snap),reasons=state.unknownReasons||{};return{bits:state.bits,re:state.re.toString(),im:state.im.toString(),span:state.span.toString(),spanExp:se.exp,directPixelRatio:directPixelRatio(snap,canvas.width),width:canvas.width,height:canvas.height,iter:maxIter(),effectiveIter:state.fieldView?.iter??maxIter(),continuationBudget:state.continuationBudget,renderMode:state.renderMode,palette:state.palette,cycle:state.cycle,shift:state.shift,colorRevision:state.colorRevision,colorAuto:state.colorAuto,rendering:state.rendering,recoloring:state.recoloring,recolorPending:state.recolorPending,lastRender:state.lastRender,renderClock:state.renderClock?{...state.renderClock}:null,engine:state.lastEngine,backend:state.fieldView?.backend||null,fastExtended:!!state.fieldView?.fastExtended,referenceReused:!!state.fieldView?.referenceReused,provisional:state.fieldView?.complete===false,qualityStage:state.qualityStage,qualityIter:state.qualityIter,qualityFinalIter:state.qualityFinalIter,previewMs:state.previewMs,refineMs:state.refineMs,refinePending:state.refinePending,temporalFill:state.temporalFill,numericRecoveryPending:state.numericRecoveryPending,numericRecoveryRunning:state.numericRecoveryRunning,historyReady:!!renderer?.historyReady,multiRefPasses:state.multiRefPasses,multiRefRecovered:state.multiRefRecovered,frontierRounds:state.frontierRounds,frontierPixels:state.frontierPixels,frontierEscaped:state.frontierEscaped,frontierIter:state.frontierIter,frontierMinimumIter:state.frontierMinimumIter,frontierConverged:state.frontierConverged,frontierMaxDispatchWork:runtime.frontierMaxDispatchWork||0,numericalFailures:numericalFailureCount({reasons}),unknownReasons:state.unknownReasons,kernelVersion:G.version,gpuStage:state.gpuStage,gpuTileRows:state.gpuTileRows,numericSubmits:runtime.numericSubmits,correctionSubmits:runtime.correctionSubmits,gpuSyncWaits:runtime.gpuSyncWaits,stageCounts:{...runtime.stageCounts},stageMs:{...runtime.stageMs},generationMetrics:runtime.generationMetrics.slice(-16).map(x=>({...x}))}}, + state:()=>{const snap=snapshot(),se=spanMantExp(snap),reasons=state.unknownReasons||{};return{bits:state.bits,re:state.re.toString(),im:state.im.toString(),span:state.span.toString(),spanExp:se.exp,directPixelRatio:directPixelRatio(snap,canvas.width),width:canvas.width,height:canvas.height,iter:maxIter(),effectiveIter:state.fieldView?.iter??maxIter(),continuationBudget:state.continuationBudget,quality:state.quality,renderMode:state.renderMode,palette:state.palette,cycle:state.cycle,shift:state.shift,colorRevision:state.colorRevision,colorAuto:state.colorAuto,rendering:state.rendering,recoloring:state.recoloring,recolorPending:state.recolorPending,lastRender:state.lastRender,renderClock:state.renderClock?{...state.renderClock}:null,engine:state.lastEngine,backend:state.fieldView?.backend||null,fastExtended:!!state.fieldView?.fastExtended,referenceReused:!!state.fieldView?.referenceReused,provisional:state.fieldView?.complete===false,qualityStage:state.qualityStage,qualityIter:state.qualityIter,qualityFinalIter:state.qualityFinalIter,previewMs:state.previewMs,refineMs:state.refineMs,refinePending:state.refinePending,temporalFill:state.temporalFill,numericRecoveryPending:state.numericRecoveryPending,numericRecoveryRunning:state.numericRecoveryRunning,historyReady:!!renderer?.historyReady,multiRefPasses:state.multiRefPasses,multiRefRecovered:state.multiRefRecovered,frontierRounds:state.frontierRounds,frontierPixels:state.frontierPixels,frontierEscaped:state.frontierEscaped,frontierIter:state.frontierIter,frontierMinimumIter:state.frontierMinimumIter,frontierConverged:state.frontierConverged,frontierMaxDispatchWork:runtime.frontierMaxDispatchWork||0,numericalFailures:numericalFailureCount({reasons}),unknownReasons:state.unknownReasons,kernelVersion:G.version,gpuStage:state.gpuStage,gpuTileRows:state.gpuTileRows,numericSubmits:runtime.numericSubmits,correctionSubmits:runtime.correctionSubmits,gpuSyncWaits:runtime.gpuSyncWaits,stageCounts:{...runtime.stageCounts},stageMs:{...runtime.stageMs},generationMetrics:runtime.generationMetrics.slice(-16).map(x=>({...x}))}}, async panPixels(dx,dy){pan(dx,dy);const start=performance.now();while((state.dirty||state.rendering)&&performance.now()-start<120000){schedule();await new Promise(r=>setTimeout(r,20))}if(state.dirty||state.rendering)throw new Error('test pan timeout');return this.state()}, async compareCurrentToFrontierBaseline({strict=true}={}){if(!renderer?.frame)throw new Error('WebGPU frame unavailable');const snap=snapshot(),iter=state.fieldView?.iter??maxIter(),w=canvas.width,h=canvas.height,view=state.fieldView||{},deep=true,exactRequired=!!view.deep,ctx=await refs.request(snap,iter,w,h);if(ctx.checkpointMismatch)throw new Error('frontier baseline reference guard checkpoint mismatch');const rows=Math.max(1,Math.floor(PIXEL_FRONTIER_WORK/Math.max(1,w*iter))),baseline=new Uint32Array(w*h);let maxDispatchWork=0;for(let y=0;y>>0,b=baseline[i]>>>0,ac=(a>>>28)&3,bc=(b>>>28)&3;if(ac===FIELD_ESCAPED)escaped++;if(bc===FIELD_ESCAPED)baselineEscaped++;const sameClass=(ac===FIELD_ESCAPED)===(bc===FIELD_ESCAPED),sameIter=ac!==FIELD_ESCAPED||(a&ITER_MASK)===(b&ITER_MASK);if(!sameClass)classMismatch++;else if(!sameIter)escapeIterationMismatch++;if(!sameClass||(exactRequired&&!sameIter)){mismatch++;disputes.push(i);if(first<0)first=i}}let oracleChecked=0,oracleInconclusive=0,oracleCurrentMismatch=0,oracleBaselineMismatch=0;if(disputes.length){const solved=await precisionFallback.request(snap,iter,w,h,disputes,-1);for(let j=0;j>>0,a=current[i]>>>0,b=baseline[i]>>>0,oc=(o>>>28)&3,ac=(a>>>28)&3,bc=(b>>>28)&3,currentSame=(oc===FIELD_ESCAPED)===(ac===FIELD_ESCAPED)&&(!exactRequired||oc!==FIELD_ESCAPED||(o&ITER_MASK)===(a&ITER_MASK)),baselineSame=(oc===FIELD_ESCAPED)===(bc===FIELD_ESCAPED)&&(!exactRequired||oc!==FIELD_ESCAPED||(o&ITER_MASK)===(b&ITER_MASK));if(!currentSame)oracleCurrentMismatch++;if(!baselineSame)oracleBaselineMismatch++}}const oracleGate=oracleCurrentMismatch===0&&oracleInconclusive===0;return{pixels:current.length,mismatch,classMismatch,escapeIterationMismatch,exactRequired,first,escaped,baselineEscaped,iter,baseline:'deep-gold',oracleChecked,oracleInconclusive,oracleCurrentMismatch,oracleBaselineMismatch,oracleGate,maxDispatchWork,workLimit:PIXEL_FRONTIER_WORK,gatePassed:oracleGate&&maxDispatchWork<=PIXEL_FRONTIER_WORK}}, async probeMeta({w,h,strict=true,forceDeep=null,correctUnknown=true}={}){if(!renderer)throw new Error('WebGPU renderer unavailable');const snap=snapshot(),iter=maxIter(),deep=forceDeep===null?deepNeeded(snap,w):!!forceDeep,fastExtended=!deep&&fastNeedsExtended(snap,w);let ctx=null,fastCtx=null;if(deep)ctx=await refs.request(snap,iter,w,h);else if(fastExtended)fastCtx=await fastRefs.request(snap,iter,w,h);return Array.from(await renderer.renderTileMeta({snap,iter,deep,deepContext:ctx,fastContext:fastCtx,fullW:w,fullH:h,w,h,forceStrict:strict,correctUnknown:deep&&correctUnknown}))}, @@ -2137,7 +2126,7 @@ globalThis.__MANDEL_TEST__={ // ── boot / teardown ────────────────────────────────────────────────────── addEventListener('visibilitychange',()=>{if(document.hidden){cancelRender();exportJob.cancelled=true}else markDirty(false)});addEventListener('pagehide',()=>{stopColorAuto(false);cancelRender();refs.destroy();fastRefs.destroy();precisionFallback.destroy();for(const service of precisionFallbackPool)service.destroy();if(renderer)renderer.destroy()},{once:true}); -try{state.uiHidden=localStorage.getItem('mandelbrot.uiHidden')==='1';const m=localStorage.getItem('mandelbrot.processMode');if(/^(power|standard|fine|validate)$/.test(m)){state.processMode=m;state.hq=m==='fine'||m==='validate'}localStorage.removeItem('mandelbrot.renderMode')}catch{}applyUi();resize();if(!loadHash())reset();recordView();syncControls();updateStats();initRenderer().then(()=>{resize();if(!state.rendering&&!state.frameView)markDirty(false)});schedule(); +try{state.uiHidden=localStorage.getItem('mandelbrot.uiHidden')==='1';applyQuality(localStorage.getItem('mandelbrot.quality')||localStorage.getItem('mandelbrot.processMode'));localStorage.removeItem('mandelbrot.processMode');localStorage.removeItem('mandelbrot.renderMode')}catch{}applyUi();resize();if(!loadHash())reset();recordView();syncControls();updateStats();initRenderer().then(()=>{resize();if(!state.rendering&&!state.frameView)markDirty(false)});schedule(); })(); diff --git a/tests/browser_smoke.mjs b/tests/browser_smoke.mjs index 7120fc9..bee73be 100644 --- a/tests/browser_smoke.mjs +++ b/tests/browser_smoke.mjs @@ -35,7 +35,7 @@ const cases=[ {id:'axis',re:'-0.75',im:'0',span:'0.02',renderMode:'fast',backend:'direct'}, {id:'black',re:'-29466147000382485924219538765424656674342940730553342389512209954887705938978413587',im:'5179018789925933872641942859702187326563010594882200888329237785122225251258352',span:'250637324516887125119843167990565159991251418774166207381437546036409',bits:273,baseIter:350,adaptive:true,fixed:true,renderMode:'accurate',backend:'fast-extended'} ]; -assert.ok(['all','kernels','cancel','bench','budget','ui','palette','pan'].includes(selection)||cases.some(c=>c.id===selection),'Unknown case'); +assert.ok(['all','kernels','cancel','bench','budget','ui','palette','pan','quality'].includes(selection)||cases.some(c=>c.id===selection),'Unknown case'); // Execute the real production kernels on small, fully specified states. // This isolates terminal and queue behavior from render convergence heuristics. @@ -137,6 +137,38 @@ try{ for(let i=0;i<100;i++){if(await evaluate('!!globalThis.__MANDEL_TEST__'))break;await sleep(100)} const ready=await evaluate('globalThis.__MANDEL_TEST__.ensureGpuReady()'); assert.equal(ready.ready,true,JSON.stringify(ready)); + if(selection==='quality'||selection==='all'){ + await evaluate('__MANDEL_TEST__.waitForNumericComplete()'); + assert.deepEqual(await evaluate('Array.from(document.querySelector("#quality").options,o=>o.textContent)'),['高速','標準','高画質']); + assert.equal(await evaluate('!!document.querySelector("#processMode, #continuationBudget")'),false); + const reports=[]; + for(const scenario of [cases[0],cases[1],cases[2]]){ + // The mid view contains many surviving pixels; this exercised unfinished + // high-quality frames in the old automatic/Strict policies. + await evaluate(`__MANDEL_TEST__.setView(${JSON.stringify({...scenario,bits:448,baseIter:512,adaptive:false,quality:'fast'})})`); + for(const [quality,width,height,budget] of [['fast',240,180,4096],['standard',320,240,16384],['high',480,360,32768]]){ + const result=await evaluate(`(async()=>{const e=document.querySelector('#quality');e.value='${quality}';e.dispatchEvent(new Event('change'));const s=await __MANDEL_TEST__.waitForNumericComplete({timeout:180000});const r=__MANDEL_TEST__.kernelAccess().renderer,field=await r.readFieldAll();let invalid=0;for(const p of field.meta){if((p&0xfffff)>${budget})invalid++;if((p>>>28)===0&&((p>>>20)!==6||(p&0xfffff)!==${budget}))invalid++}return{s,invalid,front:r.frontColorKey,png:await __MANDEL_TEST__.pngRoundTrip()}})()`); + assert.equal(result.s.width,width);assert.equal(result.s.height,height);assert.equal(result.s.continuationBudget,budget); + assert.equal(result.s.numericalFailures,0);assert.equal(result.s.provisional,false);assert.equal(result.s.renderClock.status,'complete');assert.equal(result.invalid,0); + assert.equal(result.front.split(':')[3],'0','no neighborhood blur');assert.equal(result.png.mismatches,0); + reports.push({case:scenario.id,quality,width,height,budget,ms:result.s.lastRender}); + console.log(JSON.stringify({quality:reports.at(-1)})); + } + } + // New URL/local preferences restore the quality and its resolution. + await command('Page.reload');await sleep(150);await evaluate('__MANDEL_TEST__.waitForNumericComplete({timeout:180000})'); + assert.equal(await evaluate('__MANDEL_TEST__.state().quality'),'high'); + assert.equal(await evaluate('document.querySelector("#quality").value'),'high'); + assert.equal(await evaluate('new URLSearchParams(location.hash.slice(1)).get("q")'),'high'); + // Cancel a high-resolution calculation via the actual selector. + await evaluate(`__MANDEL_TEST__.setView(${JSON.stringify({...cases[0],bits:448,baseIter:512,adaptive:false,quality:'fast'})})`); + const work=evaluate(`__MANDEL_TEST__.setView(${JSON.stringify({...cases[1],bits:448,baseIter:512,adaptive:false,quality:'high'})})`).then(()=>true,()=>false); + for(let i=0;i<500;i++){if(await evaluate('__MANDEL_TEST__.state().rendering'))break;await sleep(10)} + await evaluate('const e=document.querySelector("#quality");e.value="fast";e.dispatchEvent(new Event("change"))'); + await work;const final=await evaluate('__MANDEL_TEST__.waitForNumericComplete()'); + assert.equal(final.quality,'fast');assert.equal(final.width,240);assert.equal(final.continuationBudget,4096); + console.log('PASS: three quality presets, real supersampling, finite completion, PNG, URL reload, cancellation'); + } if(selection==='pan'||selection==='all'){ await evaluate('__MANDEL_TEST__.waitForNumericComplete()'); await evaluate('if(!document.body.classList.contains("ui-hidden"))document.querySelector("#uiToggle").click()'); diff --git a/tests/regression.mjs b/tests/regression.mjs index 92dd62b..ead5db4 100644 --- a/tests/regression.mjs +++ b/tests/regression.mjs @@ -19,7 +19,7 @@ function appContext(search=''){ const element={width:800,height:600,clientWidth:800,clientHeight:600,style:{},addEventListener(){},classList:{toggle(){}},setAttribute(){}}; const context={MANDEL_WEBGPU_KERNELS:kernels,document:{querySelector:()=>element},navigator:{hardwareConcurrency:4},location:{search},performance,URLSearchParams,TextEncoder,console,addEventListener(){}}; const code=scripts[1].slice(0,scripts[1].indexOf('// ── boot / teardown'))+ - 'globalThis.internal={state,runtime,refs,refinePixelFrontier,setTestPresentation:fn=>{presentProvisional=fn},recolor,setRecolorRenderer:r=>{renderer=r;updateStats=()=>{}},WebGpuRenderer,chooseBackend,startRenderClock,finishRenderClock,renderTimeLabel,fromDec,operationLimitIndices,numericalFailureIndices,pixelPrecisionBits,precisionBatchSize,certifiedInteriorTiles,referenceWorkerSource,fastReferenceWorkerSource,precisionFallbackWorkerSource};})();'; + 'globalThis.internal={state,runtime,refs,refinePixelFrontier,setTestPresentation:fn=>{presentProvisional=fn},applyQuality,pixelBudget,resize,recolor,setRecolorRenderer:r=>{renderer=r;updateStats=()=>{}},WebGpuRenderer,chooseBackend,startRenderClock,finishRenderClock,renderTimeLabel,fromDec,operationLimitIndices,numericalFailureIndices,pixelPrecisionBits,precisionBatchSize,certifiedInteriorTiles,referenceWorkerSource,fastReferenceWorkerSource,precisionFallbackWorkerSource};})();'; vm.runInNewContext(code,context); return context; } @@ -29,6 +29,25 @@ assert.equal(typeof diagnostic.__MANDEL_TEST__.state,'function'); const a=normal.internal; assert.equal(a.runtime.generationMetrics.length,0); +// Quality presets increase real raster dimensions, not a blur radius. Bound +// both work and memory at large windows, including low-memory devices. +{ + const c=appContext(),i=c.internal;Object.assign(c,{innerWidth:800,innerHeight:600,devicePixelRatio:1,requestAnimationFrame:()=>1}); + const dimensions=[]; + for(const [quality,budget] of [['fast',4096],['standard',16384],['high',32768]]){ + i.applyQuality(quality);i.resize(); + const canvas=c.document.querySelector('#view');dimensions.push([canvas.width,canvas.height]); + assert.equal(i.state.continuationBudget,budget);assert.equal(i.state.hq,false);assert.ok(canvas.width*canvas.height<=i.pixelBudget()+canvas.width+canvas.height); + } + assert.deepEqual(dimensions,[[600,450],[800,600],[1200,900]]); + Object.assign(c,{innerWidth:7680,innerHeight:4320,devicePixelRatio:2}); + for(const memory of [4,8])for(const quality of ['fast','standard','high']){ + c.navigator.deviceMemory=memory;i.applyQuality(quality);i.resize();const canvas=c.document.querySelector('#view'); + assert.ok(canvas.width*canvas.height<=i.pixelBudget()+canvas.width+canvas.height); + } + for(const old of ['fine','validate']){i.applyQuality(old);assert.equal(i.state.quality,'high');assert.equal(i.state.continuationBudget,32768)} +} + // A faster animation producer must not hold recoloring=true indefinitely. // Release after one GPU pass, and do not take ownership from a pending pan. { @@ -155,7 +174,7 @@ assert.equal(await renderer.applyPrecisionFallback({indices:Uint32Array.of(0)},a // Exercise the actual frontier controller with one repaired pixel and one // healthy survivor. GPU state/queue correctness is tested in browser_smoke. { - const snap={bits:256,re:0n,im:0n,span:a.fromDec('3.4')};Object.assign(a.state,snap); + const snap={bits:256,re:0n,im:0n,span:a.fromDec('3.4')};Object.assign(a.state,snap,{continuationBudget:0}); const ctx={referenceId:'fixture',key:'fixture',source:snap,refLen:4096}; a.refs.request=async()=>ctx;a.refs.requestFixed=async()=>ctx;a.setTestPresentation(async()=>true); const field=Uint32Array.of(6<<20|512,6<<20|512),replayed=[];let initializations=0,advances=0;