This commit is contained in:
33333-33333 2026-09-07 14:21:34 +09:00
commit d185d2d8e9
6 changed files with 153 additions and 28 deletions

View file

@ -34,7 +34,7 @@ dialog{width:min(430px,calc(100vw - 24px));border:1px solid var(--line);border-r
<div class="group">
<div class="line"><label for="processMode">負荷設定</label><select id="processMode"><option value="power">省電力</option><option value="standard" selected>標準</option><option value="fine">精細</option><option value="validate">保守的 (Strict)</option></select><output></output></div>
<div class="line"><label for="continuationBudget">反復上限</label><select id="continuationBudget"><option value="0">自動</option><option value="4096">4,096 回</option><option value="16384">16,384 回</option><option value="32768">32,768 回</option><option value="65536">65,536 回</option><option value="150000">150,000 回</option></select><output></output></div>
<div class="line"><label for="palette">彩色</label><select id="palette" aria-label="彩色プリセット"><option value="0">昼夜</option><option value="1">虹色</option><option value="2">白黒</option><option value="3">インフェルノ</option><option value="5">氷晶</option><option value="7">オーロラ</option><option value="9">深海</option><option value="10">黄金</option><option value="11">ネオン</option><option value="13">電光</option><option value="15">蒼橙</option><option value="17">紅碧</option><option value="18">桃翠</option></select><output></output></div>
<div class="line"><label for="palette">彩色</label><select id="palette" aria-label="彩色プリセット"><option value="0">昼夜</option><option value="1">虹色</option><option value="2">白黒</option><option value="3">インフェルノ</option><option value="5">氷晶</option><option value="7">オーロラ</option><option value="10">黄金</option><option value="13">電光</option><option value="15">蒼橙</option><option value="19">ピンクと黒</option></select><output></output></div>
<div class="line"><label for="cycle">色周期</label><input id="cycle" type="range" min="0" max="1000" step="1" value="532" aria-label="色周期(対数スケール)"><output id="cycleO" for="cycle">0.0080</output></div>
<div class="line"><label for="shift">色相位置</label><input id="shift" type="range" min="0" max="1" step="0.005" value="0.18"><output id="shiftO" for="shift">.18</output></div>
<div class="mini-actions"><button id="colorAuto" type="button" aria-pressed="false" title="色周期は固定し、色相位置だけを動かします">色相変化</button></div>
@ -819,22 +819,20 @@ fn palette_color(phase:f32)->vec3<f32>{
if(p.palette==1u){return hsv(phase,0.92,1.0);}
if(p.palette==2u){let g=(18.0+237.0*(0.5-0.5*cos(6.283185307*phase)))/255.0;return vec3<f32>(g);}
let t=tri_phase(phase);
if(p.palette==19u){return ramp5(t,rgb8(20.0,14.0,26.0),rgb8(72.0,47.0,83.0),rgb8(161.0,92.0,130.0),rgb8(224.0,130.0,164.0),rgb8(249.0,193.0,205.0));}
if(p.palette==3u){return ramp5(t,rgb8(0.0,0.0,2.0),rgb8(40.0,8.0,84.0),rgb8(141.0,36.0,101.0),rgb8(228.0,88.0,48.0),rgb8(252.0,255.0,166.0));}
if(p.palette==5u){return ramp5(t,rgb8(1.0,4.0,14.0),rgb8(7.0,36.0,92.0),rgb8(0.0,132.0,178.0),rgb8(116.0,232.0,240.0),rgb8(242.0,254.0,255.0));}
if(p.palette==7u){return ramp5(t,rgb8(3.0,6.0,20.0),rgb8(63.0,26.0,132.0),rgb8(10.0,146.0,174.0),rgb8(48.0,224.0,140.0),rgb8(229.0,255.0,184.0));}
if(p.palette==9u){return ramp5(t,rgb8(0.0,5.0,20.0),rgb8(4.0,42.0,82.0),rgb8(0.0,113.0,136.0),rgb8(34.0,176.0,162.0),rgb8(221.0,246.0,202.0));}
if(p.palette==10u){return ramp5(t,rgb8(5.0,4.0,2.0),rgb8(44.0,24.0,4.0),rgb8(132.0,83.0,10.0),rgb8(228.0,171.0,46.0),rgb8(255.0,245.0,198.0));}
if(p.palette==11u){return ramp5(t,rgb8(3.0,0.0,16.0),rgb8(250.0,0.0,132.0),rgb8(0.0,228.0,255.0),rgb8(126.0,255.0,0.0),rgb8(255.0,247.0,255.0));}
if(p.palette==13u){return ramp5(t,rgb8(0.0,0.0,14.0),rgb8(21.0,16.0,152.0),rgb8(0.0,188.0,255.0),rgb8(220.0,0.0,255.0),rgb8(255.0,255.0,255.0));}
if(p.palette==15u){return ramp5(t,rgb8(2.0,8.0,26.0),rgb8(18.0,85.0,210.0),rgb8(192.0,244.0,255.0),rgb8(255.0,134.0,36.0),rgb8(255.0,242.0,196.0));}
if(p.palette==17u){return ramp5(t,rgb8(20.0,1.0,14.0),rgb8(204.0,26.0,72.0),rgb8(255.0,226.0,182.0),rgb8(0.0,202.0,226.0),rgb8(226.0,249.0,255.0));}
if(p.palette==18u){return ramp5(t,rgb8(22.0,8.0,24.0),rgb8(171.0,36.0,122.0),rgb8(255.0,211.0,149.0),rgb8(45.0,204.0,176.0),rgb8(232.0,255.0,243.0));}
return current_palette(phase);
}
fn interior_color()->vec3<f32>{return select(vec3<f32>(0.0),rgb8(14.0,10.0,19.0),p.palette==19u);}
fn escaped_color(m:u32,sm:f32)->vec3<f32>{
// Direct phase mapping: no iteration-band/anchor compensation.
let phase=fract(p.shift+sm*p.cycle);
let c=palette_color(phase);let n=f32(m&0x000fffffu);let edge=clamp(log(1.0+n)/log(1.0+max(8.0,n+32.0)),0.0,1.0);let mixv=0.30+0.70*pow(edge,0.40);let floorc=select(vec3<f32>(0.0,0.0,0.0),vec3<f32>(2.0/255.0),p.palette==2u);return mix(floorc,c,mixv);
let c=palette_color(phase);let n=f32(m&0x000fffffu);let edge=clamp(log(1.0+n)/log(1.0+max(8.0,n+32.0)),0.0,1.0);let mixv=0.30+0.70*pow(edge,0.40);let floorc=select(interior_color(),vec3<f32>(2.0/255.0),p.palette==2u);return mix(floorc,c,mixv);
}
fn base_color(i:u32)->vec3<f32>{
let m=fieldMeta[i];return escaped_color(m,fieldSmooth[i]);
@ -848,8 +846,8 @@ fn main(@builtin(global_invocation_id) gid:vec3<u32>){
if(cls==FIELD_UNKNOWN){
let reason=(m>>20u)&0xffu;
// Iteration-limit pixels are not a numeric hole: display them as opaque
// black so temporal history cannot paint stale exterior colour through.
if(reason==6u){textureStore(outTex,vec2<i32>(gid.xy),vec4<f32>(0.0,0.0,0.0,1.0));}
// a dark opaque color so temporal history cannot paint stale exterior colour through.
if(reason==6u){textureStore(outTex,vec2<i32>(gid.xy),vec4<f32>(interior_color(),1.0));}
else{textureStore(outTex,vec2<i32>(gid.xy),vec4<f32>(0.0));}
return;
}
@ -857,7 +855,7 @@ fn main(@builtin(global_invocation_id) gid:vec3<u32>){
// Certified / likely interior is real image content, not a temporal hole.
// Keep it opaque so a reprojected history frame can never leak exterior
// colour into an already-known Mandelbrot interior pixel.
textureStore(outTex,vec2<i32>(gid.xy),vec4<f32>(0.0,0.0,0.0,1.0));
textureStore(outTex,vec2<i32>(gid.xy),vec4<f32>(interior_color(),1.0));
return;
}
var c=escaped_color(m,fieldSmooth[i]);
@ -1002,7 +1000,7 @@ globalThis.MANDEL_WEBGPU_KERNELS=Object.freeze({
const G=globalThis.MANDEL_WEBGPU_KERNELS;if(!G)throw new Error('gpu-kernels.js が読み込まれていません');
const $=s=>document.querySelector(s),canvas=$('#view');
const VERSION=24,INITIAL_BITS=256,MIN_SPAN_BITS=224,TARGET_SPAN_BITS=240,RATIO_DEN=4503599627370496n;
const CYCLE_MIN=.001,CYCLE_MAX=.05,CYCLE_SLIDER_MAX=1000,PALETTE_MAX=18,VALID_PALETTE_IDS=new Set([0,1,2,3,5,7,9,10,11,13,15,17,18]);
const CYCLE_MIN=.001,CYCLE_MAX=.05,CYCLE_SLIDER_MAX=1000,PALETTE_MAX=19,VALID_PALETTE_IDS=new Set([0,1,2,3,5,7,10,13,15,19]);
const FIELD_UNKNOWN=0,FIELD_ESCAPED=1,FIELD_INTERIOR_LIKELY=2,FIELD_INTERIOR_PROVEN=3,ITER_MASK=0x000fffff,REASON_SHIFT=20;
const UNKNOWN_REASON_NAMES=['none','error-bound','escape-uncertain','reference-end','rebase-gap','range','operation-limit','ds-sensitivity'];
const NUMERIC_PARAM_BYTES=96,UNRESOLVED_BYTES=32,SPARSE_QUEUE_STATS_BYTES=32,SPARSE_INDIRECT_BYTES=16,DEEP_BUCKET_STATE_BYTES=96;
@ -1051,7 +1049,8 @@ 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 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 restoreView(v){if(!v)return;Object.assign(state,{bits:v.bits,re:v.re,im:v.im,span:v.span,palette:v.palette,cycle:v.cycle,shift:v.shift,baseIter:v.baseIter,adaptive:v.adaptive,continuationBudget:v.continuationBudget||0,renderMode:'fast'});ensurePrecision();syncControls();saveHash(false);markDirty()}
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()}
// ── iteration / quality policy ───────────────────────────────────────────
const ADAPTIVE_ITER_QUANTUM=64;
@ -1515,9 +1514,9 @@ class WebGpuRenderer{
const source=this.colorSource;this.colorSource=null;this.colorSourceVisible=false;
if(source){this.clearBindGroupCache();for(const k of ['meta','smooth','params','texture'])destroy(source[k])}
}
captureColorSource(view=state.frameView){
captureColorSource(view=state.frameView,needed=state.colorAuto||state.rendering){
this.colorSourceVisible=false;
if(!state.colorAuto){this.releaseColorSource();return}
if(!needed){this.releaseColorSource();return}
const f=this.frame;if(!f||!view)return;
const d=this.device,B=GPUBufferUsage,T=GPUTextureUsage;
if(!this.colorSource){this.colorSource={
@ -1533,11 +1532,15 @@ class WebGpuRenderer{
recolorPublished(){
// Only published numerical data is sampled during in-flight refinement.
// This pass uses its own uniforms/texture and never swaps numeric front/back.
const source=this.colorSource,f=this.frame;if(!source||!f)return false;
const source=this.colorSource,f=this.frame;if(!source||!f||this.publishedColorWork)return false;
const d=this.device;d.queue.writeBuffer(source.params,0,this.colorParamsData(f.w,f.h));
const bg=this.cachedBindGroup(this.color,[{binding:0,resource:{buffer:source.params}},{binding:1,resource:{buffer:source.meta}},{binding:2,resource:{buffer:source.smooth}},{binding:3,resource:this.cachedTextureView(source.texture)}]);
const e=d.createCommandEncoder({label:'animate-published-color'}),p=e.beginComputePass();p.setPipeline(this.color);p.setBindGroup(0,bg);p.dispatchWorkgroups(Math.ceil(f.w/8),Math.ceil(f.h/8));p.end();d.queue.submit([e.finish()]);
this.colorSourceVisible=true;runtime.gpuRecolors++;this.presentFrame();return true;
this.colorSourceVisible=true;runtime.gpuRecolors++;this.presentFrame();
// Bound optional color work even when the GPU is slower than animation.
const work=d.queue.onSubmittedWorkDone();this.publishedColorWork=work;
const done=()=>{if(this.publishedColorWork===work)this.publishedColorWork=null};work.then(done,done);
return true;
}
async recolor(token,iter=state.fieldView?.iter??maxIter()){await this.ready;if(token!==state.token||!this.frame)return false;const f=this.frame,d=this.device,colorStyle=colorStyleSnapshot(),colorKey=colorStyleKey(colorStyle);d.queue.writeBuffer(f.colorParams,0,this.colorParamsData(f.w,f.h,iter,colorStyle));const e=d.createCommandEncoder(),bg=this.cachedBindGroup(this.color,[{binding:0,resource:{buffer:f.colorParams}},{binding:1,resource:{buffer:f.meta}},{binding:2,resource:{buffer:f.smooth}},{binding:3,resource:this.cachedTextureView(this.colorTarget(f))}]),p=e.beginComputePass();p.setPipeline(this.color);p.setBindGroup(0,bg);p.dispatchWorkgroups(Math.ceil(f.w/8),Math.ceil(f.h/8));p.end();d.queue.submit([e.finish()]);await d.queue.onSubmittedWorkDone();if(token!==state.token)return false;[f.front,f.back]=[f.back,f.front];this.frontColorKey=colorKey;runtime.gpuRecolors++;return true}
presentTransform(view=state.frameView){if(!this.frame||!view)return{scaleX:1,scaleY:1,offsetX:0,offsetY:0};const cur=snapshot(),b=Math.max(cur.bits,view.bits),cs=align(cur.span,cur.bits,b),ps=align(view.span,view.bits,b),dr=align(cur.re,cur.bits,b)-align(view.re,view.bits,b),di=align(cur.im,cur.bits,b)-align(view.im,view.bits,b),scale=fixedRatio(cs,ps);return{scaleX:scale,scaleY:scale,offsetX:fixedRatio(dr,ps),offsetY:-fixedRatio(di,ps)*this.frame.w/Math.max(1,this.frame.h)}}
@ -1838,7 +1841,7 @@ function startRenderClock(token,startedAt){
function finishRenderClock(token,status='complete'){
const c=state.renderClock;if(!c||c.token!==token||c.status!=='running')return;
c.elapsed=performance.now()-c.startedAt;c.status=status;
if(status==='complete')state.lastRender=c.elapsed;
if(status==='complete'){state.lastRender=c.elapsed;if(!state.colorAuto&&!state.recolorPending)renderer?.releaseColorSource()}
clearInterval(renderClockTimer);renderClockTimer=0;
$('#render').textContent=renderTimeLabel();
}
@ -1911,7 +1914,7 @@ async function runQualityRefinement(snap,token,stages,index){
}
async function renderFrame(){
const token=++state.token,snap=snapshot(),finalIter=maxIter(),holdStable=canUseStableReprojection(),stages=qualityStages(finalIter),iter=stages[0],isSingleFinal=stages.length===1,t0=performance.now();
if(renderer&&state.fieldView?.complete&&state.colorAuto)renderer.captureColorSource(state.frameView);
if(renderer&&state.fieldView?.complete)renderer.captureColorSource(state.frameView,true);
startRenderClock(token,t0);
const generation=TEST_MODE?{token,startedAt:t0,viewKey:[snap.bits,snap.re,snap.im,snap.span].join(':'),width:canvas.width,height:canvas.height,previewIter:iter,finalIter,numericSubmitsStart:runtime.numericSubmits,correctionSubmitsStart:runtime.correctionSubmits,gpuSyncWaitsStart:runtime.gpuSyncWaits,status:'running'}:null;if(generation){runtime.generationMetrics.push(generation);if(runtime.generationMetrics.length>16)runtime.generationMetrics.shift();}
state.rendering=true;state.dirty=false;state.drawState=isSingleFinal&&holdStable?'REPROJECTED':'COVERING';state.unresolved=0;state.unknownReasons=null;state.correctionPasses=0;state.correctedPixels=0;state.multiRefPasses=0;state.multiRefRecovered=0;state.frontierRounds=0;state.frontierPixels=0;state.frontierEscaped=0;state.frontierIter=iter;state.frontierMinimumIter=PIXEL_FRONTIER_ENABLED?pixelFrontierIterationFloor(snap,canvas.width,iter):iter;state.frontierConverged=false;state.qualityStage=isSingleFinal?'final':'preview';state.qualityIter=iter;state.qualityFinalIter=finalIter;state.previewMs=0;state.refineMs=0;state.refinePending=false;runtime.renderStarts++;runtime.stageCounts={};runtime.stageMs={};runtime.frontierMaxDispatchWork=0;updateStats();
@ -1933,9 +1936,19 @@ async function renderFrame(){
if(!isSingleFinal)scheduleQualityRefinement(snap,token,stages,1);
}catch(e){if(generation){generation.status=token===state.token?'error':'cancelled';generation.error=String(e&&e.message||e);}if(token!==state.token)return;finishRenderClock(token,'error');state.drawState='ERROR';state.rendering=false;state.refinePending=false;state.gpuError=String(e&&e.message||e);state.lastEngine='WebGPU error';updateStats();console.error(e)}
}
let recolorRaf=0;
function requestRecolor(){
state.colorRevision++;state.recolorPending=true;
if(state.rendering||state.numericRecoveryRunning){renderer?.recolorPublished();return}
queueRecolor();
}
function queueRecolor(){
if(recolorRaf)return;
recolorRaf=requestAnimationFrame(()=>{recolorRaf=0;flushRecolor()});
}
function flushRecolor(){
if(state.pointerActive||state.wheelActive)return;
if(state.dirty){schedule();return}
if(state.rendering||state.numericRecoveryRunning){if(renderer?.colorSource&&!renderer.recolorPublished())queueRecolor();return}
// Keep the old completed frame valid until the replacement colour pass has
// actually finished. Invalidating history at slider-down caused apparent
// non-response/flicker when GPU work was already in flight.
@ -1943,10 +1956,10 @@ function requestRecolor(){
}
async function recolor(){
if(!renderer?.frame||!state.fieldView){state.recolorPending=true;return false}
if(state.rendering||state.recoloring||state.numericRecoveryRunning){state.recolorPending=true;return false}
if(state.dirty||state.pointerActive||state.wheelActive||state.rendering||state.recoloring||state.numericRecoveryRunning){state.recolorPending=true;return false}
state.recoloring=true;let painted=false;
try{
while(state.recolorPending&&!state.rendering&&!state.numericRecoveryRunning&&renderer?.frame&&state.fieldView){
while(state.recolorPending&&!state.dirty&&!state.pointerActive&&!state.wheelActive&&!state.rendering&&!state.numericRecoveryRunning&&renderer?.frame&&state.fieldView){
state.recolorPending=false;const revision=state.colorRevision,token=state.token,ok=await renderer.recolor(token);
if(!ok||token!==state.token){state.recolorPending=true;continue}
renderer.colorSourceVisible=false;
@ -1957,10 +1970,13 @@ async function recolor(){
// latest value instead of replaying stale colour states.
if(revision===state.colorRevision&&state.qualityStage==='final'&&!state.refinePending&&numericFrameComplete()){renderer.commitHistory(state.frameView);state.temporalFill=false}
updateStats();
// Yield ownership after one pass: a stream of hue changes must not keep
// recoloring=true forever and prevent a pending pan from computing.
break;
}
return painted
}catch(e){console.error(e);return false}
finally{state.recoloring=false;if(state.dirty)schedule();if(state.recolorPending&&renderer?.frame&&!state.rendering&&!state.numericRecoveryRunning)queueMicrotask(recolor)}
finally{state.recoloring=false;if(state.dirty)schedule();else if(state.recolorPending&&renderer?.frame&&!state.pointerActive&&!state.wheelActive&&!state.rendering&&!state.numericRecoveryRunning)queueRecolor()}
}
function loop(){raf=0;if(state.pointerActive||state.wheelActive){if(renderer&&state.frameView)renderer.presentFrame(renderer.presentTransform());updateStats();return}if(state.dirty&&!state.rendering&&!state.recoloring)renderFrame();else if(renderer&&state.frameView)renderer.presentFrame(renderer.presentTransform())}
@ -1997,7 +2013,7 @@ $('#share').onclick=copyShareUrl;
let colorAutoRaf=0,colorAutoLast=0,colorAutoPaint=0;
function syncColorAutoButton(){const b=$('#colorAuto');if(!b)return;b.textContent=state.colorAuto?'色相変化停止':'色相変化';b.classList.toggle('on',state.colorAuto);b.setAttribute('aria-pressed',state.colorAuto?'true':'false')}
function stopColorAuto(repaint=true){state.colorAuto=false;colorAutoLast=0;if(colorAutoRaf){cancelAnimationFrame(colorAutoRaf);colorAutoRaf=0}syncColorAutoButton();if(repaint)requestRecolor()}
function colorAutoStep(now){if(!state.colorAuto){colorAutoRaf=0;return}if(!colorAutoLast)colorAutoLast=now;const dt=Math.min(.1,Math.max(0,(now-colorAutoLast)/1000));colorAutoLast=now;/* Hue animation advances shift directly. No cycle or phase compensation is applied. */state.shift=fract01(state.shift+.09*dt);$('#shift').value=String(state.shift);$('#shiftO').textContent=state.shift.toFixed(2);if(now-colorAutoPaint>=50){colorAutoPaint=now;requestRecolor()}colorAutoRaf=requestAnimationFrame(colorAutoStep)}
function colorAutoStep(now){if(!state.colorAuto){colorAutoRaf=0;return}if(!colorAutoLast)colorAutoLast=now;const dt=Math.min(.1,Math.max(0,(now-colorAutoLast)/1000));colorAutoLast=now;/* Hue animation advances shift directly. No cycle or phase compensation is applied. */state.shift=fract01(state.shift+.09*dt);$('#shift').value=String(state.shift);$('#shiftO').textContent=state.shift.toFixed(2);if(!state.pointerActive&&!state.wheelActive&&now-colorAutoPaint>=(state.rendering?100:50)){colorAutoPaint=now;requestRecolor()}colorAutoRaf=requestAnimationFrame(colorAutoStep)}
$('#colorAuto').onclick=()=>{state.colorAuto=!state.colorAuto;syncColorAutoButton();if(state.colorAuto&&!colorAutoRaf){if(!state.rendering&&!state.numericRecoveryRunning)renderer?.captureColorSource();colorAutoRaf=requestAnimationFrame(colorAutoStep);}else if(!state.colorAuto)stopColorAuto()};
$('#palette').addEventListener('change',e=>{const pal=Math.max(0,Math.min(PALETTE_MAX,Number(e.target.value)|0));state.palette=VALID_PALETTE_IDS.has(pal)?pal:0;requestRecolor();saveHash(false)});
function applyCycleControl(e){state.cycle=sliderToCycle(e.target.value);$('#cycleO').textContent=state.cycle.toFixed(4);requestRecolor()}
@ -2006,8 +2022,8 @@ $('#cycle').addEventListener('input',applyCycleControl);$('#cycle').addEventList
$('#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{}};
addEventListener('resize',()=>{resize();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);if(loadHash()){syncControls();recordView();markDirty()}});
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()}});
// ── export: GPU tiled + streaming PNG, optional GPU 2x2 supersampling ──────
const exportJob={active:false,cancelled:false};