471 lines
26 KiB
HTML
471 lines
26 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>SymClaw — GPU Function Manipulator</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{background:#0d1117;color:#c9d1d9;font-family:'SF Mono',Consolas,'Courier New',monospace;display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
||
#toolbar{display:flex;gap:8px;padding:10px 14px;background:#161b22;border-bottom:1px solid #30363d;align-items:center;flex-wrap:wrap}
|
||
#toolbar label{color:#8b949e;font-size:12px}
|
||
#expr-input{flex:1;min-width:200px;background:#0d1117;border:1px solid #30363d;color:#58a6ff;padding:6px 10px;border-radius:4px;font-family:inherit;font-size:14px}
|
||
#expr-input:focus{outline:none;border-color:#58a6ff}
|
||
.btn{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:12px;font-family:inherit}
|
||
.btn:hover{background:#30363d}
|
||
.btn.active{background:#1f6feb;border-color:#1f6feb;color:#fff}
|
||
select{background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:4px 6px;border-radius:4px;font-size:12px;font-family:inherit}
|
||
#sliders{padding:6px 14px;background:#161b22;border-bottom:1px solid #30363d;display:flex;gap:16px;flex-wrap:wrap;min-height:0}
|
||
#sliders:empty{display:none}
|
||
.slider-group{display:flex;align-items:center;gap:6px}
|
||
.slider-group label{font-size:12px;color:#8b949e;min-width:14px}
|
||
.slider-group input[type=range]{width:120px;accent-color:#58a6ff}
|
||
.slider-group .val{font-size:12px;color:#58a6ff;min-width:40px;text-align:right}
|
||
#canvas-wrap{flex:1;position:relative;overflow:hidden}
|
||
canvas{display:block;width:100%;height:100%}
|
||
#info{position:absolute;bottom:8px;left:10px;font-size:11px;color:#484f58;pointer-events:none}
|
||
#expr-display{position:absolute;top:8px;left:10px;font-size:13px;color:#e6edf3;background:rgba(13,17,23,0.85);padding:4px 8px;border-radius:4px;pointer-events:none}
|
||
#stats{position:absolute;top:8px;right:10px;font-size:11px;color:#8b949e;background:rgba(13,17,23,0.85);padding:6px 10px;border-radius:4px;pointer-events:none;text-align:right;line-height:1.6}
|
||
#stats .gpu-label{color:#3fb950;font-weight:bold}
|
||
#stats .cpu-label{color:#d29922;font-weight:bold}
|
||
#fallback-msg{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#d29922;font-size:14px;display:none;text-align:center;background:rgba(13,17,23,0.9);padding:12px 20px;border-radius:8px;border:1px solid #30363d}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="toolbar">
|
||
<label>f(x) =</label>
|
||
<input id="expr-input" type="text" value="a*sin(b*x + c)" spellcheck="false">
|
||
<select id="res-select" title="Resolution">
|
||
<option value="1000">1K pts</option>
|
||
<option value="10000" selected>10K pts</option>
|
||
<option value="100000">100K pts</option>
|
||
<option value="1000000">1M pts</option>
|
||
</select>
|
||
<button class="btn active" id="btn-mode">GPU</button>
|
||
<button class="btn" id="btn-zin" title="Zoom In">+</button>
|
||
<button class="btn" id="btn-zout" title="Zoom Out">−</button>
|
||
<button class="btn" id="btn-reset" title="Reset View">⌂</button>
|
||
<button class="btn active" id="btn-grid" title="Toggle Grid">Grid</button>
|
||
</div>
|
||
<div id="sliders"></div>
|
||
<div id="canvas-wrap">
|
||
<canvas id="plot"></canvas>
|
||
<div id="expr-display"></div>
|
||
<div id="info"></div>
|
||
<div id="stats"></div>
|
||
<div id="fallback-msg"></div>
|
||
</div>
|
||
<script>
|
||
// ===== Expression Parser (shared) =====
|
||
const FUNCS={sin:Math.sin,cos:Math.cos,tan:Math.tan,exp:Math.exp,ln:Math.log,log:Math.log,sqrt:Math.sqrt,abs:Math.abs,asin:Math.asin,acos:Math.acos,atan:Math.atan,ceil:Math.ceil,floor:Math.floor};
|
||
const CONSTS={pi:Math.PI,e:Math.E};
|
||
const WGSL_FUNCS={sin:'sin',cos:'cos',tan:'tan',exp:'exp',ln:'log',log:'log',sqrt:'sqrt',abs:'abs',asin:'asin',acos:'acos',atan:'atan',ceil:'ceil',floor:'floor'};
|
||
|
||
function tokenize(s){const t=[];let i=0;while(i<s.length){const c=s[i];if(' \t'.includes(c)){i++;continue}if('0123456789.'.includes(c)){let n='';while(i<s.length&&'0123456789.'.includes(s[i]))n+=s[i++];t.push({t:'num',v:parseFloat(n)});continue}if(/[a-zA-Z_]/.test(c)){let n='';while(i<s.length&&/[a-zA-Z_0-9]/.test(s[i]))n+=s[i++];t.push({t:'id',v:n});continue}if('+-*/^(),'.includes(c)){t.push({t:'op',v:c});i++;continue}throw new Error('Unexpected: '+c)}return t}
|
||
|
||
function parseTokens(tokens){let pos=0;function peek(){return pos<tokens.length?tokens[pos]:null}function eat(v){const t=tokens[pos++];if(v&&t.v!==v)throw new Error('Expected '+v);return t}
|
||
function expr(){return addSub()}
|
||
function addSub(){let l=mulDiv();while(peek()&&(peek().v==='+'||peek().v==='-')){const op=eat().v;l={t:'bin',op,l,r:mulDiv()}}return l}
|
||
function mulDiv(){let l=unary();while(peek()&&(peek().v==='*'||peek().v==='/')){const op=eat().v;l={t:'bin',op,l,r:unary()}}return l}
|
||
function unary(){if(peek()&&peek().v==='-'){eat();return{t:'bin',op:'*',l:{t:'num',v:-1},r:power()}}if(peek()&&peek().v==='+'){eat();return power()}return power()}
|
||
function power(){let l=atom();if(peek()&&peek().v==='^'){eat();l={t:'bin',op:'^',l,r:unary()}}return l}
|
||
function atom(){const tk=peek();if(!tk)throw new Error('Unexpected end');if(tk.t==='num'){eat();return{t:'num',v:tk.v}}if(tk.t==='id'){const name=tk.v;eat();if(peek()&&peek().v==='('){eat('(');const args=[expr()];while(peek()&&peek().v===','){eat(',');args.push(expr())}eat(')');return{t:'call',name,args}}if(CONSTS[name]!==undefined)return{t:'num',v:CONSTS[name]};return{t:'var',name}}if(tk.v==='('){eat('(');const e=expr();eat(')');return e}throw new Error('Unexpected: '+tk.v)}
|
||
const tree=expr();if(pos<tokens.length)throw new Error('Trailing: '+tokens[pos].v);return tree}
|
||
|
||
function evaluate(node,vars){if(node.t==='num')return node.v;if(node.t==='var')return vars[node.name]!==undefined?vars[node.name]:NaN;if(node.t==='bin'){const l=evaluate(node.l,vars),r=evaluate(node.r,vars);switch(node.op){case'+':return l+r;case'-':return l-r;case'*':return l*r;case'/':return l/r;case'^':return Math.pow(l,r)}}if(node.t==='call'){const fn=FUNCS[node.name];if(!fn)return NaN;return fn(...node.args.map(a=>evaluate(a,vars)))}return NaN}
|
||
|
||
function findParams(node,s=new Set()){if(node.t==='var'&&node.name!=='x')s.add(node.name);if(node.l)findParams(node.l,s);if(node.r)findParams(node.r,s);if(node.args)node.args.forEach(a=>findParams(a,s));return s}
|
||
|
||
function compileExpr(str){try{const tree=parseTokens(tokenize(str));return{tree,params:[...findParams(tree)].sort(),err:null}}catch(e){return{tree:null,params:[],err:e.message}}}
|
||
|
||
// ===== AST to WGSL =====
|
||
function astToWgsl(node, paramNames){
|
||
if(node.t==='num'){const v=node.v;return Number.isInteger(v)?v.toFixed(1):String(v)}
|
||
if(node.t==='var'){if(node.name==='x')return'x';const idx=paramNames.indexOf(node.name);return idx>=0?`p[${idx}]`:'0.0'}
|
||
if(node.t==='bin'){const l=astToWgsl(node.l,paramNames),r=astToWgsl(node.r,paramNames);if(node.op==='^')return`pow(${l},${r})`;return`(${l} ${node.op} ${r})`}
|
||
if(node.t==='call'){const wn=WGSL_FUNCS[node.name];if(!wn)return'0.0';return`${wn}(${node.args.map(a=>astToWgsl(a,paramNames)).join(', ')})`}
|
||
return'0.0';
|
||
}
|
||
|
||
function makeComputeShader(tree, paramNames){
|
||
const body=astToWgsl(tree,paramNames);
|
||
return`
|
||
@group(0) @binding(0) var<storage, read> x_vals: array<f32>;
|
||
@group(0) @binding(1) var<storage, read_write> y_vals: array<f32>;
|
||
@group(0) @binding(2) var<uniform> p: array<vec4<f32>, 2>;
|
||
|
||
@compute @workgroup_size(256)
|
||
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||
let i = id.x;
|
||
if (i >= arrayLength(&x_vals)) { return; }
|
||
let x = x_vals[i];
|
||
y_vals[i] = ${body};
|
||
}`;
|
||
}
|
||
|
||
// ===== State =====
|
||
let viewX=0,viewY=0,scaleX=50,scaleY=50;
|
||
let showGrid=true,tree=null,params=[],paramVals={};
|
||
let dragging=false,dragStart={x:0,y:0},viewStart={x:0,y:0};
|
||
let gpuMode=true, numPoints=10000;
|
||
let gpuReady=false, gpuName='';
|
||
let device,context,canvasFormat,computePipeline,computeBGL;
|
||
let xBuffer,yBuffer,paramBuffer,stagingBuffer;
|
||
let lastExprStr='', lastShaderSrc='';
|
||
let fpsFrames=0,fpsTime=performance.now(),fps=0;
|
||
let evalTimeMs=0,renderTimeMs=0;
|
||
|
||
const canvas=document.getElementById('plot');
|
||
const input=document.getElementById('expr-input');
|
||
const slidersDiv=document.getElementById('sliders');
|
||
const exprDisplay=document.getElementById('expr-display');
|
||
const infoDiv=document.getElementById('info');
|
||
const statsDiv=document.getElementById('stats');
|
||
const fallbackMsg=document.getElementById('fallback-msg');
|
||
const resSelect=document.getElementById('res-select');
|
||
const modeBtn=document.getElementById('btn-mode');
|
||
|
||
// ===== CPU fallback =====
|
||
let ctx2d=null;
|
||
function initCPU(){ctx2d=canvas.getContext('2d')}
|
||
|
||
function toScreen(wx,wy){const cx=canvas.width/2,cy=canvas.height/2;return[cx+(wx-viewX)*scaleX*devicePixelRatio,cy-(wy-viewY)*scaleY*devicePixelRatio]}
|
||
function toWorld(sx,sy){const cx=canvas.width/2,cy=canvas.height/2;return[(sx-cx)/(scaleX*devicePixelRatio)+viewX,-(sy-cy)/(scaleY*devicePixelRatio)+viewY]}
|
||
function niceStep(range){const rough=range/8,mag=Math.pow(10,Math.floor(Math.log10(rough))),norm=rough/mag;if(norm<1.5)return mag;if(norm<3.5)return 2*mag;if(norm<7.5)return 5*mag;return 10*mag}
|
||
|
||
function drawCPU(){
|
||
const W=canvas.width,H=canvas.height,dpr=devicePixelRatio;
|
||
ctx2d.clearRect(0,0,W,H);ctx2d.fillStyle='#0d1117';ctx2d.fillRect(0,0,W,H);
|
||
const[xMin]=toWorld(0,0),[xMax]=toWorld(W,0),[,yMax]=toWorld(0,0),[,yMin]=toWorld(0,H);
|
||
if(showGrid){const sx=niceStep(xMax-xMin),sy=niceStep(yMax-yMin);ctx2d.strokeStyle='#21262d';ctx2d.lineWidth=1;ctx2d.font=(11*dpr)+'px monospace';ctx2d.fillStyle='#484f58';ctx2d.textAlign='center';ctx2d.textBaseline='top';for(let gx=Math.floor(xMin/sx)*sx;gx<=xMax;gx+=sx){const[px]=toScreen(gx,0);ctx2d.beginPath();ctx2d.moveTo(px,0);ctx2d.lineTo(px,H);ctx2d.stroke();if(Math.abs(gx)>sx*0.1){const[,py]=toScreen(0,0);ctx2d.fillText(+gx.toFixed(6),px,Math.min(Math.max(py+4*dpr,2*dpr),H-14*dpr))}}ctx2d.textAlign='right';ctx2d.textBaseline='middle';for(let gy=Math.floor(yMin/sy)*sy;gy<=yMax;gy+=sy){const[,py]=toScreen(0,gy);ctx2d.beginPath();ctx2d.moveTo(0,py);ctx2d.lineTo(W,py);ctx2d.stroke();if(Math.abs(gy)>sy*0.1){const[px]=toScreen(0,0);ctx2d.fillText(+gy.toFixed(6),Math.min(Math.max(px-4*dpr,40*dpr),W-2*dpr),py)}}}
|
||
ctx2d.strokeStyle='#30363d';ctx2d.lineWidth=1.5*dpr;const[ax]=toScreen(0,0);if(ax>=0&&ax<=W){ctx2d.beginPath();ctx2d.moveTo(ax,0);ctx2d.lineTo(ax,H);ctx2d.stroke()}const[,ay]=toScreen(0,0);if(ay>=0&&ay<=H){ctx2d.beginPath();ctx2d.moveTo(0,ay);ctx2d.lineTo(W,ay);ctx2d.stroke()}
|
||
if(!tree)return;
|
||
const t0=performance.now();
|
||
const vars={...paramVals};ctx2d.strokeStyle='#58a6ff';ctx2d.lineWidth=2*dpr;ctx2d.beginPath();let first=true,prevOk=false;
|
||
const steps=Math.min(numPoints,100000);
|
||
for(let i=0;i<=steps;i++){const wx=xMin+(xMax-xMin)*i/steps;vars.x=wx;const wy=evaluate(tree,vars);if(!isFinite(wy)){prevOk=false;continue}const[sx,sy]=toScreen(wx,wy);if(first||!prevOk){ctx2d.moveTo(sx,sy);first=false}else ctx2d.lineTo(sx,sy);prevOk=true}
|
||
ctx2d.stroke();
|
||
evalTimeMs=performance.now()-t0;renderTimeMs=evalTimeMs;
|
||
}
|
||
|
||
// ===== WebGPU Init =====
|
||
async function initGPU(){
|
||
if(!navigator.gpu){showFallback('WebGPU not available — using CPU rendering');return false}
|
||
const adapter=await navigator.gpu.requestAdapter();
|
||
if(!adapter){showFallback('No GPU adapter — using CPU rendering');return false}
|
||
gpuName=(await adapter.requestAdapterInfo()).description||adapter.name||'GPU';
|
||
device=await adapter.requestDevice();
|
||
canvasFormat=navigator.gpu.getPreferredCanvasFormat();
|
||
context=canvas.getContext('webgpu');
|
||
context.configure({device,format:canvasFormat,alphaMode:'premultiplied'});
|
||
allocateBuffers();
|
||
return true;
|
||
}
|
||
|
||
function allocateBuffers(){
|
||
const size=numPoints*4;
|
||
xBuffer=device.createBuffer({size,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST});
|
||
yBuffer=device.createBuffer({size,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC});
|
||
paramBuffer=device.createBuffer({size:32,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
|
||
stagingBuffer=device.createBuffer({size,usage:GPUBufferUsage.MAP_READ|GPUBufferUsage.COPY_DST});
|
||
}
|
||
|
||
function rebuildComputePipeline(){
|
||
if(!tree||!device)return false;
|
||
const src=makeComputeShader(tree,params);
|
||
if(src===lastShaderSrc)return true;
|
||
lastShaderSrc=src;
|
||
try{
|
||
const module=device.createShaderModule({code:src});
|
||
computePipeline=device.createComputePipeline({layout:'auto',compute:{module,entryPoint:'main'}});
|
||
computeBGL=computePipeline.getBindGroupLayout(0);
|
||
return true;
|
||
}catch(e){console.error('Shader error:',e);return false}
|
||
}
|
||
|
||
function uploadParams(){
|
||
const data=new Float32Array(8);
|
||
params.forEach((p,i)=>{if(i<8)data[i]=paramVals[p]||0});
|
||
device.queue.writeBuffer(paramBuffer,0,data);
|
||
}
|
||
|
||
function uploadXValues(){
|
||
const W=canvas.width,H=canvas.height;
|
||
const[xMin]=toWorld(0,0),[xMax]=toWorld(W,0);
|
||
const data=new Float32Array(numPoints);
|
||
for(let i=0;i<numPoints;i++)data[i]=xMin+(xMax-xMin)*i/(numPoints-1);
|
||
device.queue.writeBuffer(xBuffer,0,data);
|
||
}
|
||
|
||
async function computeOnGPU(){
|
||
if(!computePipeline)return null;
|
||
uploadXValues();uploadParams();
|
||
const bg=device.createBindGroup({layout:computeBGL,entries:[{binding:0,resource:{buffer:xBuffer}},{binding:1,resource:{buffer:yBuffer}},{binding:2,resource:{buffer:paramBuffer}}]});
|
||
const enc=device.createCommandEncoder();
|
||
const pass=enc.beginComputePass();
|
||
pass.setPipeline(computePipeline);pass.setBindGroup(0,bg);
|
||
pass.dispatchWorkgroups(Math.ceil(numPoints/256));pass.end();
|
||
enc.copyBufferToBuffer(yBuffer,0,stagingBuffer,0,numPoints*4);
|
||
device.queue.submit([enc.finish()]);
|
||
await stagingBuffer.mapAsync(GPUMapMode.READ);
|
||
const result=new Float32Array(stagingBuffer.getMappedRange().slice(0));
|
||
stagingBuffer.unmap();
|
||
return result;
|
||
}
|
||
|
||
async function drawGPU(){
|
||
if(!tree||!device)return;
|
||
// We render with Canvas2D overlay for grid/axes, and GPU for compute
|
||
// Get a 2d context from an offscreen approach — actually, since we configured webgpu,
|
||
// we'll draw the plot data with the 2d fallback on a separate overlay or just read back and draw.
|
||
// Simplest correct approach: GPU compute + CPU Canvas2D render (still huge speedup for eval)
|
||
|
||
const W=canvas.width,H=canvas.height,dpr=devicePixelRatio;
|
||
const[xMin]=toWorld(0,0),[xMax]=toWorld(W,0),[,yMax]=toWorld(0,0),[,yMin]=toWorld(0,H);
|
||
|
||
// GPU compute
|
||
const t0=performance.now();
|
||
if(!rebuildComputePipeline()){drawFallbackError();return}
|
||
const yVals=await computeOnGPU();
|
||
evalTimeMs=performance.now()-t0;
|
||
if(!yVals)return;
|
||
|
||
// Render to canvas via 2D (we need to get a 2d context)
|
||
// Since we used webgpu context, we draw to an offscreen then blit...
|
||
// Actually let's just use a second canvas for the 2D overlay
|
||
renderGPUResult(yVals,xMin,xMax);
|
||
}
|
||
|
||
// We'll use an offscreen canvas for 2D drawing since main canvas has webgpu context
|
||
let offCanvas,offCtx;
|
||
function ensureOffCanvas(){
|
||
if(!offCanvas){offCanvas=document.createElement('canvas');offCtx=offCanvas.getContext('2d')}
|
||
offCanvas.width=canvas.width;offCanvas.height=canvas.height;
|
||
}
|
||
|
||
function renderGPUResult(yVals,xMin,xMax){
|
||
const t0=performance.now();
|
||
const W=canvas.width,H=canvas.height,dpr=devicePixelRatio;
|
||
const[,yMax2]=toWorld(0,0),[,yMin2]=toWorld(0,H);
|
||
|
||
// Draw to webgpu canvas by copying from offscreen
|
||
ensureOffCanvas();
|
||
const c=offCtx;
|
||
c.clearRect(0,0,W,H);c.fillStyle='#0d1117';c.fillRect(0,0,W,H);
|
||
|
||
// Grid
|
||
if(showGrid){const sx=niceStep(xMax-xMin),sy=niceStep(yMax2-yMin2);c.strokeStyle='#21262d';c.lineWidth=1;c.font=(11*dpr)+'px monospace';c.fillStyle='#484f58';c.textAlign='center';c.textBaseline='top';for(let gx=Math.floor(xMin/sx)*sx;gx<=xMax;gx+=sx){const[px]=toScreen(gx,0);c.beginPath();c.moveTo(px,0);c.lineTo(px,H);c.stroke();if(Math.abs(gx)>sx*0.1){const[,py]=toScreen(0,0);c.fillText(+gx.toFixed(6),px,Math.min(Math.max(py+4*dpr,2*dpr),H-14*dpr))}}c.textAlign='right';c.textBaseline='middle';for(let gy=Math.floor(yMin2/sy)*sy;gy<=yMax2;gy+=sy){const[,py]=toScreen(0,gy);c.beginPath();c.moveTo(0,py);c.lineTo(W,py);c.stroke();if(Math.abs(gy)>sy*0.1){const[px]=toScreen(0,0);c.fillText(+gy.toFixed(6),Math.min(Math.max(px-4*dpr,40*dpr),W-2*dpr),py)}}}
|
||
|
||
// Axes
|
||
c.strokeStyle='#30363d';c.lineWidth=1.5*dpr;const[ax]=toScreen(0,0);if(ax>=0&&ax<=W){c.beginPath();c.moveTo(ax,0);c.lineTo(ax,H);c.stroke()}const[,ay]=toScreen(0,0);if(ay>=0&&ay<=H){c.beginPath();c.moveTo(0,ay);c.lineTo(W,ay);c.stroke()}
|
||
|
||
// Plot curve
|
||
c.strokeStyle='#58a6ff';c.lineWidth=2*dpr;c.beginPath();
|
||
let first=true,prevOk=false;
|
||
const step=Math.max(1,Math.floor(numPoints/W));// downsample for drawing if needed
|
||
for(let i=0;i<numPoints;i+=step){
|
||
const wx=xMin+(xMax-xMin)*i/(numPoints-1);
|
||
const wy=yVals[i];
|
||
if(!isFinite(wy)){prevOk=false;continue}
|
||
const[sx,sy]=toScreen(wx,wy);
|
||
if(first||!prevOk){c.moveTo(sx,sy);first=false}else c.lineTo(sx,sy);
|
||
prevOk=true;
|
||
}
|
||
c.stroke();
|
||
renderTimeMs=performance.now()-t0;
|
||
|
||
// Blit to webgpu canvas via copyExternalImageToTexture
|
||
const tex=context.getCurrentTexture();
|
||
device.queue.copyExternalImageToTexture({source:offCanvas},{texture:tex},[W,H]);
|
||
}
|
||
|
||
function drawFallbackError(){
|
||
ensureOffCanvas();offCtx.clearRect(0,0,canvas.width,canvas.height);
|
||
offCtx.fillStyle='#0d1117';offCtx.fillRect(0,0,canvas.width,canvas.height);
|
||
offCtx.fillStyle='#f85149';offCtx.font='14px monospace';offCtx.fillText('Shader compilation error',20,30);
|
||
const tex=context.getCurrentTexture();
|
||
device.queue.copyExternalImageToTexture({source:offCanvas},{texture:tex},[canvas.width,canvas.height]);
|
||
}
|
||
|
||
// ===== Draw dispatcher =====
|
||
let drawing=false;
|
||
async function draw(){
|
||
if(drawing)return;drawing=true;
|
||
try{
|
||
if(gpuMode&&gpuReady){await drawGPU()}
|
||
else{drawCPU()}
|
||
updateDisplay();updateFPS();
|
||
}finally{drawing=false}
|
||
}
|
||
|
||
function updateFPS(){
|
||
fpsFrames++;const now=performance.now();
|
||
if(now-fpsTime>=1000){fps=Math.round(fpsFrames*1000/(now-fpsTime));fpsFrames=0;fpsTime=now}
|
||
}
|
||
|
||
function updateDisplay(){
|
||
if(!tree){exprDisplay.textContent='';statsDiv.innerHTML='';return}
|
||
let s=input.value;params.forEach(p=>{s=s.replace(new RegExp('\\b'+p+'\\b','g'),Number(paramVals[p]||0).toFixed(2))});
|
||
exprDisplay.textContent='f(x) = '+s;
|
||
const mode=gpuMode&&gpuReady;
|
||
statsDiv.innerHTML=`<span class="${mode?'gpu':'cpu'}-label">${mode?'⚡ GPU':'🖥 CPU'}</span><br>${numPoints.toLocaleString()} points<br>Eval: ${evalTimeMs.toFixed(1)}ms<br>Render: ${renderTimeMs.toFixed(1)}ms<br>FPS: ${fps}${mode?'<br><span style="color:#484f58">'+gpuName+'</span>':''}`;
|
||
infoDiv.textContent=`Scale: ${scaleX.toFixed(1)} px/unit | View: (${viewX.toFixed(2)}, ${viewY.toFixed(2)})`;
|
||
}
|
||
|
||
function showFallback(msg){fallbackMsg.textContent=msg;fallbackMsg.style.display='block';setTimeout(()=>fallbackMsg.style.display='none',4000)}
|
||
|
||
// ===== Sliders =====
|
||
function buildSliders(){
|
||
slidersDiv.innerHTML='';
|
||
params.forEach(p=>{
|
||
if(paramVals[p]===undefined)paramVals[p]=1;
|
||
const g=document.createElement('div');g.className='slider-group';
|
||
const lbl=document.createElement('label');lbl.textContent=p;
|
||
const inp=document.createElement('input');inp.type='range';inp.min='-5';inp.max='5';inp.step='0.05';inp.value=paramVals[p];
|
||
const val=document.createElement('span');val.className='val';val.textContent=Number(paramVals[p]).toFixed(2);
|
||
inp.addEventListener('input',()=>{paramVals[p]=parseFloat(inp.value);val.textContent=Number(inp.value).toFixed(2);lastShaderSrc='';draw()});
|
||
g.append(lbl,inp,val);slidersDiv.append(g);
|
||
});
|
||
}
|
||
|
||
function updateExpr(){
|
||
const r=compileExpr(input.value);tree=r.tree;params=r.params;
|
||
if(r.err){exprDisplay.textContent='Error: '+r.err;tree=null}
|
||
lastShaderSrc='';buildSliders();draw();
|
||
}
|
||
|
||
// ===== Interaction =====
|
||
function zoom(factor,cx,cy){if(!cx){cx=canvas.width/2;cy=canvas.height/2}const[wx,wy]=toWorld(cx,cy);scaleX*=factor;scaleY*=factor;scaleX=Math.max(1,Math.min(1e5,scaleX));scaleY=Math.max(1,Math.min(1e5,scaleY));const[nwx,nwy]=toWorld(cx,cy);viewX+=wx-nwx;viewY+=wy-nwy;draw()}
|
||
|
||
canvas.addEventListener('wheel',e=>{e.preventDefault();const r=canvas.getBoundingClientRect();zoom(e.deltaY<0?1.15:1/1.15,(e.clientX-r.left)*devicePixelRatio,(e.clientY-r.top)*devicePixelRatio)},{passive:false});
|
||
canvas.addEventListener('mousedown',e=>{dragging=true;dragStart={x:e.clientX,y:e.clientY};viewStart={x:viewX,y:viewY};canvas.style.cursor='grabbing'});
|
||
window.addEventListener('mousemove',e=>{if(!dragging)return;viewX=viewStart.x-(e.clientX-dragStart.x)/scaleX;viewY=viewStart.y+(e.clientY-dragStart.y)/scaleY;draw()});
|
||
window.addEventListener('mouseup',()=>{dragging=false;canvas.style.cursor='crosshair'});
|
||
|
||
let touches=[];
|
||
canvas.addEventListener('touchstart',e=>{e.preventDefault();touches=[...e.touches];if(touches.length===1){dragging=true;dragStart={x:touches[0].clientX,y:touches[0].clientY};viewStart={x:viewX,y:viewY}}},{passive:false});
|
||
canvas.addEventListener('touchmove',e=>{e.preventDefault();const t=[...e.touches];if(t.length===1&&dragging){viewX=viewStart.x-(t[0].clientX-dragStart.x)/scaleX;viewY=viewStart.y+(t[0].clientY-dragStart.y)/scaleY;draw()}else if(t.length===2&&touches.length===2){const d0=Math.hypot(touches[0].clientX-touches[1].clientX,touches[0].clientY-touches[1].clientY),d1=Math.hypot(t[0].clientX-t[1].clientX,t[0].clientY-t[1].clientY);zoom(d1/d0,(t[0].clientX+t[1].clientX)/2*devicePixelRatio,(t[0].clientY+t[1].clientY)/2*devicePixelRatio);touches=t}},{passive:false});
|
||
canvas.addEventListener('touchend',()=>{dragging=false;touches=[]});
|
||
|
||
document.getElementById('btn-zin').onclick=()=>zoom(1.3);
|
||
document.getElementById('btn-zout').onclick=()=>zoom(1/1.3);
|
||
document.getElementById('btn-reset').onclick=()=>{viewX=0;viewY=0;scaleX=50;scaleY=50;draw()};
|
||
document.getElementById('btn-grid').onclick=function(){showGrid=!showGrid;this.classList.toggle('active');draw()};
|
||
|
||
modeBtn.onclick=function(){
|
||
gpuMode=!gpuMode;
|
||
this.textContent=gpuMode&&gpuReady?'GPU':'CPU';
|
||
this.classList.toggle('active',gpuMode&&gpuReady);
|
||
// Switch canvas context
|
||
reinitCanvas();
|
||
draw();
|
||
};
|
||
|
||
resSelect.onchange=function(){
|
||
numPoints=parseInt(this.value);
|
||
if(gpuReady&&gpuMode){allocateBuffers();lastShaderSrc=''}
|
||
draw();
|
||
};
|
||
|
||
function reinitCanvas(){
|
||
// Recreate canvas to switch context type
|
||
const wrap=canvas.parentElement;
|
||
const newCanvas=document.createElement('canvas');
|
||
newCanvas.id='plot';newCanvas.style.cssText=canvas.style.cssText;
|
||
// Copy event listeners by replacing
|
||
wrap.replaceChild(newCanvas,canvas);
|
||
// Update ref — but we need to re-bind events... simpler: just reload
|
||
// Actually let's just use the offscreen approach for GPU and always keep 2d
|
||
// Hmm, this is getting complicated. Let me simplify the architecture.
|
||
}
|
||
|
||
// ===== Simplified Architecture =====
|
||
// GPU compute + always Canvas2D render. This avoids context switching issues.
|
||
// The GPU acceleration is for the COMPUTE (expression evaluation), not rendering.
|
||
// This is where the real perf win is anyway (1M point eval on GPU vs CPU).
|
||
|
||
async function init(){
|
||
// Always use 2D context for rendering
|
||
ctx2d=canvas.getContext('2d');
|
||
|
||
// Try to init WebGPU for compute
|
||
if(navigator.gpu){
|
||
try{
|
||
const adapter=await navigator.gpu.requestAdapter();
|
||
if(adapter){
|
||
gpuName=(await adapter.requestAdapterInfo()).description||'WebGPU';
|
||
device=await adapter.requestDevice();
|
||
allocateBuffers();
|
||
gpuReady=true;
|
||
}else{showFallback('No GPU adapter — using CPU');gpuMode=false}
|
||
}catch(e){showFallback('WebGPU init failed — using CPU');gpuMode=false}
|
||
}else{showFallback('WebGPU not available — using CPU');gpuMode=false}
|
||
|
||
if(!gpuReady){modeBtn.textContent='CPU';modeBtn.classList.remove('active')}
|
||
|
||
canvas.style.cursor='crosshair';
|
||
input.addEventListener('input',updateExpr);
|
||
window.addEventListener('resize',resize);
|
||
resize();updateExpr();
|
||
}
|
||
|
||
// Override drawGPU to use 2D context directly
|
||
drawGPU=async function(){
|
||
if(!tree||!device)return;
|
||
const W=canvas.width,H=canvas.height,dpr=devicePixelRatio;
|
||
const[xMin]=toWorld(0,0),[xMax]=toWorld(W,0),[,yMax2]=toWorld(0,0),[,yMin2]=toWorld(0,H);
|
||
|
||
ctx2d.clearRect(0,0,W,H);ctx2d.fillStyle='#0d1117';ctx2d.fillRect(0,0,W,H);
|
||
|
||
// Grid + axes (same as CPU)
|
||
if(showGrid){const sx=niceStep(xMax-xMin),sy=niceStep(yMax2-yMin2);ctx2d.strokeStyle='#21262d';ctx2d.lineWidth=1;ctx2d.font=(11*dpr)+'px monospace';ctx2d.fillStyle='#484f58';ctx2d.textAlign='center';ctx2d.textBaseline='top';for(let gx=Math.floor(xMin/sx)*sx;gx<=xMax;gx+=sx){const[px]=toScreen(gx,0);ctx2d.beginPath();ctx2d.moveTo(px,0);ctx2d.lineTo(px,H);ctx2d.stroke();if(Math.abs(gx)>sx*0.1){const[,py]=toScreen(0,0);ctx2d.fillText(+gx.toFixed(6),px,Math.min(Math.max(py+4*dpr,2*dpr),H-14*dpr))}}ctx2d.textAlign='right';ctx2d.textBaseline='middle';for(let gy=Math.floor(yMin2/sy)*sy;gy<=yMax2;gy+=sy){const[,py]=toScreen(0,gy);ctx2d.beginPath();ctx2d.moveTo(0,py);ctx2d.lineTo(W,py);ctx2d.stroke();if(Math.abs(gy)>sy*0.1){const[px]=toScreen(0,0);ctx2d.fillText(+gy.toFixed(6),Math.min(Math.max(px-4*dpr,40*dpr),W-2*dpr),py)}}}
|
||
ctx2d.strokeStyle='#30363d';ctx2d.lineWidth=1.5*dpr;const[ax]=toScreen(0,0);if(ax>=0&&ax<=W){ctx2d.beginPath();ctx2d.moveTo(ax,0);ctx2d.lineTo(ax,H);ctx2d.stroke()}const[,ay]=toScreen(0,0);if(ay>=0&&ay<=H){ctx2d.beginPath();ctx2d.moveTo(0,ay);ctx2d.lineTo(W,ay);ctx2d.stroke()}
|
||
|
||
// GPU compute
|
||
const t0=performance.now();
|
||
if(!rebuildComputePipeline()){exprDisplay.textContent='Shader error';return}
|
||
const yVals=await computeOnGPU();
|
||
evalTimeMs=performance.now()-t0;
|
||
if(!yVals)return;
|
||
|
||
// Draw curve
|
||
const tr=performance.now();
|
||
ctx2d.strokeStyle='#58a6ff';ctx2d.lineWidth=2*dpr;ctx2d.beginPath();
|
||
let first=true,prevOk=false;
|
||
// Downsample to ~2x canvas width for drawing efficiency
|
||
const maxDraw=Math.min(numPoints,W*2);
|
||
const step=Math.max(1,Math.floor(numPoints/maxDraw));
|
||
for(let i=0;i<numPoints;i+=step){
|
||
const wx=xMin+(xMax-xMin)*i/(numPoints-1);
|
||
const wy=yVals[i];
|
||
if(!isFinite(wy)||Math.abs(wy)>1e10){prevOk=false;continue}
|
||
const[sx,sy]=toScreen(wx,wy);
|
||
if(first||!prevOk){ctx2d.moveTo(sx,sy);first=false}else ctx2d.lineTo(sx,sy);
|
||
prevOk=true;
|
||
}
|
||
ctx2d.stroke();
|
||
renderTimeMs=performance.now()-tr;
|
||
};
|
||
|
||
function resize(){
|
||
const r=canvas.parentElement.getBoundingClientRect();
|
||
canvas.width=r.width*devicePixelRatio;canvas.height=r.height*devicePixelRatio;
|
||
canvas.style.width=r.width+'px';canvas.style.height=r.height+'px';
|
||
draw();
|
||
}
|
||
|
||
// Remove the reinitCanvas complexity
|
||
reinitCanvas=function(){};
|
||
modeBtn.onclick=function(){
|
||
if(!gpuReady){showFallback('WebGPU not available');return}
|
||
gpuMode=!gpuMode;
|
||
this.textContent=gpuMode?'GPU':'CPU';
|
||
this.classList.toggle('active',gpuMode);
|
||
lastShaderSrc='';draw();
|
||
};
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|