From 675837513a5ab878190718fa5993b0228f40c302 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:22:24 +0000 Subject: [PATCH] Bring ShadyTracer's copy home: async pipelines, the pipeline cache, read/uniform buffers (0.5.0) ShadyTracer kept its own copy of tinywebgpu.js in RAYTRACER_WEBGPU/lib and had moved well ahead of the release: async pipelines with a ready promise, a per-device compute pipeline cache keyed by the whole text, moduleOnly, the `read ` and `uniform ` resource prefixes, timePasses, the extra error scopes, and option pass-through in makeCompute/makeCompute2D. ShadyTracer is moving to a repository of its own that consumes this one as a submodule, so the copy has to stop being the newer of the two. The additions are taken whole; upstream had nothing the copy lacked. Two things the move found. The check a dispatch makes before opening its pass asked for a bind group before asking for the pipeline, so an async pipeline still compiling, with its resources already parked, said "No resources bound" to a caller that had bound them; the pipeline is asked first now. And build-min's export splice refused the minified output, because esbuild folds the module-level cache declaration into the factory's statement; it now splits those declarators off. layout.test.mjs takes the copy's own cases (a write merging into the struct, read and uniform buffers) and gains the cache, async and moduleOnly ones nothing exercised here before. What rests on DIAG or on internal names is asked of the source build only. npm run test:all passes; dist rebuilt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EyK5zGwaAPiFoLVs2YbzTo --- dist/tinywebgpu.min.js | 2 +- dist/tinywebgpu.tiny.js | 2 +- docs/API.md | 36 +++++- docs/CHANGELOG.md | 33 +++++- package.json | 2 +- src/tinywebgpu.d.ts | 31 ++++- src/tinywebgpu.js | 250 ++++++++++++++++++++++++++++++++++------ test/layout.test.mjs | 72 ++++++++++++ tools/build-min.mjs | 8 +- 9 files changed, 391 insertions(+), 45 deletions(-) diff --git a/dist/tinywebgpu.min.js b/dist/tinywebgpu.min.js index 71495be..24ba5a0 100644 --- a/dist/tinywebgpu.min.js +++ b/dist/tinywebgpu.min.js @@ -1 +1 @@ -/*! TinyWebGPU 0.4.0 | MIT | github.com/lampmaker/tinywebgpu */export let WEBGPU=()=>{let{min:e,max:t,round:r,clz32:a,ceil:n}=Math,o=0,i=0,s=0,u=0,f=0,l=!1,c=0,m={get device(){return o},set device(e){o=e},context:0,format:0,features:0,debug:!1,defines:"",onDeviceLost:0,init:async(e=0,t={},r=navigator.gpu,a,n,o,i)=>(r||M(1,"WebGPU not supported"),typeof e===_&&(e=document.querySelector(a=e)||M(23,`init: nothing matches '${a}'.`)),e?.getContext&&(e=e.getContext("webgpu")),(a=await r.requestAdapter())||M(2,"No GPU adapter"),o={...t.limits},["maxStorageBufferBindingSize","maxBufferSize"].forEach(e=>o[e]??=a.limits[e]),n=t.features??[],(o=await a.requestDevice({requiredLimits:o,requiredFeatures:n.filter(e=>a.features.has(e))})).lost.then(e=>"destroyed"!==e.reason&&m.onDeviceLost&&m.onDeviceLost(e)),i=r.getPreferredCanvasFormat(),e&&e.configure({device:o,format:i,alphaMode:t.alphaMode??"opaque",usage:t.canvasUsage??x}),U(m,{device:o,context:e,format:i,features:o.features})),destroy:()=>(G(),i=0,H(),Y.t.forEach(e=>e.n.destroy()),Y.t=[],Y.i=0,J.forEach(e=>e.forEach(e=>e.destroy())),J.clear(),le.forEach(e=>e.destroy()),le.clear(),$e.clear(),re.clear(),o&&o.destroy(),o=0),makeFrag:(e,t,r,a={})=>m.makeDraw({...a,uniforms:t,resources:r,code:`struct VSOut {${C}position) pos: ${A}, @location(0) uv: ${k}};\n@vertex fn vs_main(${C}vertex_index) i: u32) -> VSOut {\nvar ndc = array<${k},3>(${k}(-1.,-3.),${k}(-1.,1.),${k}(3.,1.))[i];\nvar o: VSOut; o.pos = ${A}(ndc, 0.0, 1.0); o.uv = ndc * 0.5 + ${k}(0.5); return o;\n}\n${e}\n@fragment fn fs_main(vs: VSOut) -> ${a.targets?"FSOut":`@location(0) ${A}`} { return frag(vs.uv); }\n`.trim()}),makeDraw:(e,t)=>(t=e.targets?T(e.targets):0,Be({...e,u:!1,d:t,code:t?`struct FSOut {${t.map((e,t)=>`@location(${t}) ${e}: ${A}`).join(", ")}};\n${e.code}`:e.code,format:t?S(e.targets):e.format})),makeCompute:(e,t,r,a,{wg:n=[8,8,1]}={})=>Be({code:`${e}\n@compute @workgroup_size(${n=[n[0]??1,n[1]??1,n[2]??1]})\nfn main(${C}global_invocation_id) gid: ${F}, ${C}local_invocation_id) lid: ${F}, ${C}workgroup_id) wid: ${F}) {\n${t}\n}\n`.trim(),uniforms:r,resources:a,u:!0,wg:n}),makeQuad:({frag:e,uniforms:t,resources:r,clear:a,...n},o)=>(o=m.makeFrag(e,t,r,n),U(o,{run:(e={},t=fe())=>(e&&T(e).length&&(o.uniforms=e),o.drawTo(t,a))})),makeCompute2D:({body:e,decls:t="",uniforms:r,resources:a,size:n=[1,1],wg:o},i,s)=>(i=m.makeCompute(t,e,r,a,{wg:o}),s=i.run,U(i,{run:(e=n[0],t=n[1],r=1,a)=>s(e,t,r,a)})),beginFrame:(e={})=>{if(G(),i)try{m.endFrame()}catch{i=0}i=I(!1),H(e.view)},endFrame:e=>i&&(G(),Q(),e=i,i=0,H(),q(e)),frame:(e,t)=>{m.beginFrame(t);try{return e()}finally{m.endFrame()}},beginCompute:(e={})=>u||(i||(i=I(!1),l=!0),c=0,u=i.beginComputePass(f=e)),endCompute:()=>(G(),l&&m.endFrame()),createStorageBuffer:(e,t,r,a,n,o)=>(r=m.createBuffer((t="number"==typeof e?0:e)?D(t):e,w|d|p,"storage"),n=(a=r.b).size,o=(e=n,t=0,r=R)=>("function"==typeof e&&([r,e]=[e,n]),K(e,(e,r,n)=>e.copyBufferToBuffer(a,t,r,0,n),!1).then(e=>r?new r(e):e)),t&&r.w(t),U(r,{r:o,clear:()=>i?N(()=>i.clearBuffer(a,0,n)):V(e=>e.clearBuffer(a,0,n),!1),read:o})),createBuffer:(e,t,r="buffer",a,n)=>({b:a=W({size:e=e+3&-4,usage:t}),w:n=he(a),buffer:a,write:n}),createUniformBuffer:e=>W({size:e,usage:g|p}),createIndirectBuffer:()=>m.createBuffer(12,h|w|p),writeUniforms:(e,t,r=0)=>o.queue.writeBuffer(e,r,t.buffer??t,t.byteOffset??0,D(t)),createTexture:(e,t,r="rgba8unorm",a)=>X(e,t,r,(a="number"==typeof a?{usage:a}:a??{}).usage??x|b|$|y,"texture",!0===a.mips?ee(e,t):a.mips||1),createStorageTexture:(e,t,r="rgba32float",a)=>X(e,t,r,a??b|v|$|y,"storageTexture"),createSampler:({magFilter:e="nearest",minFilter:t="nearest",wrapU:r=z,wrapV:a=z,wrapW:n,...i}={})=>o.createSampler({magFilter:e,minFilter:t,addressModeU:r,addressModeV:a,...n&&{addressModeW:n},...i}),writeTexture:(e,t,r={},a,n)=>(a=ge(e,r),n=me(e.format),r.bytesPerRow||n||M(9,`writeTexture: ${de(e.format)}; pass bytesPerRow explicitly.`),o.queue.writeTexture(pe(e,r),t,{bytesPerRow:r.bytesPerRow??a[0]*n,rowsPerImage:a[1]},we(...a)),e),loadTexture:async(e,t={},r=e,a=0,n,i,s)=>(typeof e===_?((a=await fetch(e)).ok||M(20,`loadTexture: HTTP ${a.status} for '${e}'.`),a=await a.blob()):e instanceof Blob?a=e:typeof HTMLImageElement<"u"&&e instanceof HTMLImageElement&&(e.complete||await e.decode(),a=e),a&&(r=await createImageBitmap(a)),i=r.videoHeight||r.displayHeight||r.height,(n=r.videoWidth||r.displayWidth||r.width)&&i||M(10,"loadTexture: could not determine source dimensions."),s=t.texture??m.createTexture(n,i,t.format,{usage:t.usage,mips:t.mips}),o.queue.copyExternalImageToTexture({source:r,flipY:t.flipY??!1},{texture:s,premultipliedAlpha:t.premultipliedAlpha??!1,colorSpace:t.colorSpace??"srgb"},we(n,i)),r!==e&&"function"==typeof r.close&&r.close(),t.mips&&m.generateMipmaps(s),s),readTexture:async(e,t={})=>{let[r,a]=ge(e,t),n=me(e.format);n||M(11,`readTexture: ${de(e.format)}.`);let o=r*n,i=o+255&-256,s=new R(await K(i*a,(n,o)=>n.copyTextureToBuffer(pe(e,t),{buffer:o,bytesPerRow:i,rowsPerImage:a},we(r,a)),!1)),u=new R(o*a);for(let e=0;e{let t=re.get(e.format);(!t||t.o!==m.defines)&&re.set(e.format,t={o:m.defines,p:m.makeFrag(`fn frag(uv: ${k}) -> ${A} { return textureSample(src, samp, ${k}(uv.x, 1.0 - uv.y)); }`,{},{src:"texture_2d",samp:"sampler"},{format:e.format})});let r=t.p;te||=m.createSampler({magFilter:"linear",minFilter:"linear"});let a=!i,n=t=>e.createView({baseMipLevel:t,mipLevelCount:1});a&&m.beginFrame();for(let t=1;tt={read:e(),write:e(),swap:()=>[t.read,t.write]=[t.write,t.read]},createPingPong:e=>m.pingPong(()=>m.createStorageBuffer(e)),createPingPongTexture:(e,t,r="rgba16float",a)=>m.pingPong(()=>m.createStorageTexture(e,t,r,a)),resizeCanvas:(a=m.context?.canvas,n={},i,s,u,f)=>(a||M(8,"resizeCanvas: no canvas — init() with a context, or pass one."),s=(i=(a,i)=>t(1,e(o?o.limits.maxTextureDimension2D:1/0,r((a||i)*(n.dpr??globalThis.devicePixelRatio??1)))))(a.clientWidth,a.width),u=i(a.clientHeight,a.height),(f=a.width!==s||a.height!==u)&&(a.width=s,a.height=u),{...we(s,u),changed:f}),show:(e,t,r={},a=r.format??t?.format??m.format,n,o,i,s)=>(n=/uint$/.test(e.format)?"u32":/sint$/.test(e.format)?"i32":"f32",(!(i=$e.get(o=`${a}|${n}|${r.flipY?1:0}`))||i.o!==m.defines)&&(s=m.makeFrag(`fn frag(uv: ${k}) -> ${A} {\n let d = ${k}(textureDimensions(src));\n let c = vec2(clamp(${k}(uv.x, ${r.flipY?"uv.y":"1.0 - uv.y"}) * d, ${k}(0.0), d - 1.0));\n return ${A}(textureLoad(src, c, 0)) * UB.scale + UB.offset;\n }`,{scale:A,offset:A},{src:`texture_2d<${n}>`},{format:a}),$e.set(o,i={o:m.defines,p:s})),(s=i.p).setResources({src:e}),s.setUniforms({scale:r.scale??[1,1,1,1],offset:r.offset??[0,0,0,0]}),s.drawTo(t,r.clear),s),save:async(e,t="capture.png",r={})=>{/^(rgba|bgra)8unorm(-srgb)?$/.test(e.format)||M(19,`save: needs an 8-bit RGBA texture, got '${e.format}'. Render it into an rgba8unorm target first.`);let[a,n]=ge(e,r),o=await m.readTexture(e,{...r,Ctor:R});if(e.format.startsWith("bgra"))for(let e=0;eURL.revokeObjectURL(e.href),1e4)}return s},makeShader:e=>be(e),bindGroup:(e,t,r)=>o.createBindGroup({layout:e.getBindGroupLayout(t),entries:r})},d=4,p=8,g=64,w=128,h=256,$=1,y=2,b=4,v=8,x=16,{entries:B,keys:T,assign:U,values:S,fromEntries:P}=Object,k="vec2",A="vec4",F="vec3",C="@builtin(",_="string",z="clamp-to-edge",O=(e,t,r=e=>({srcFactor:e,dstFactor:t,operation:"add"}))=>({color:r(e),alpha:r("one")}),E={alpha:O("src-alpha","one-minus-src-alpha"),premultiplied:O("one","one-minus-src-alpha"),additive:O("one","one")},M=(e,t)=>{throw Error(t||e)},R=Uint8Array,L=ArrayBuffer,W=e=>o.createBuffer(e),D=e=>e.byteLength,I=e=>o.createCommandEncoder(),q=e=>o.queue.submit([e.finish()]),V=(e,t,r=I(t))=>(e(r),q(r)),j=(e,t)=>e||i?t(e||i):V(t),G=()=>{try{u&&u.end()}catch{}u=0},H=e=>(s=e,u=f=c=0,l=!1),N=(e,t=u)=>(G(),e(),t&&(u=i.beginComputePass(f||{}),c&&c(u))),Y={t:[],i:0,h:1<<18},Z=(e,r,a,n)=>{let o=n+3&-4;for(;Y.t[Y.i]&&Y.t[Y.i].n.size-Y.t[Y.i].en&&s.l.fill(0,s.e+n,s.e+o);let f=s.e;N(()=>i.copyBufferToBuffer(s.n,f,e,r,o)),s.e+=o},Q=()=>(Y.t.forEach(e=>(e.e&&o.queue.writeBuffer(e.n,0,e.l,0,e.e),e.e=0)),Y.i=0),J=new Map,K=async(e,t,r,a,n,o)=>(n=m._acquireStaging(a=e+3&-4),V(e=>t(e,n,a),r),await n.mapAsync(1),o=n.getMappedRange(0,a).slice(0,e),n.unmap(),m._releaseStaging(n),o),X=(e,t,r,a,n,i=1)=>o.createTexture({size:we(e,t),format:r,mipLevelCount:i,sampleCount:1,usage:a}),ee=(e,r)=>32-a(t(e,r)),te=0,re=new Map,ae=new WeakMap,ne=0,oe=new WeakMap,ie=new WeakMap,se=(e,t=oe.get(e))=>t||(oe.set(e,t=e.createView()),ie.set(t,[e.width,e.height]),t),ue=e=>e.createView?se(e):e,fe=(e=s||m.context&&se(m.context.getCurrentTexture()))=>(e||M(7,"No render target: init() was called without a canvas context — pass an explicit view."),i&&(s=e),e),le=new Map,ce=(e,t,r=ie.get(e)||M(21,"depth: target size unknown. Pass a GPUTexture (not a view) to drawTo, or supply depth.texture."),a=`${r[0]}x${r[1]} ${t}`,n)=>se(le.get(a)||(le.size>=4&&(n=le.keys().next().value,le.get(n).destroy(),le.delete(n)),le.set(a,n=X(r[0],r[1],t,x,"depth")),n)),me=e=>{let t=/^(r|rg|rgba|bgra)(8|16|32)/.exec(e);return t?t[1].length*(t[2]/8):{rgb10a2unorm:4,rgb10a2uint:4,rg11b10ufloat:4}[e]},de=e=>`unknown bytes-per-texel for format '${e}'`,pe=(e,t)=>({texture:e,mipLevel:t.mipLevel??0,origin:{x:t.x??0,y:t.y??0}}),ge=(e,t)=>[t.width??e.width,t.height??e.height],we=(e,t)=>({width:e,height:t}),he=e=>(t,r=0,a)=>(L.isView(t)||t instanceof L||M(5,"buffer write: expected a TypedArray or ArrayBuffer."),3&r&&M(6,`buffer write: byteOffset must be a multiple of 4 (got ${r}).`),i?Z(e,r,t,D(t)):(3&D(t)&&((a=new R(D(t)+3&-4)).set(L.isView(t)?new R(t.buffer,t.byteOffset,D(t)):new R(t)),t=a),m.writeUniforms(e,t,r))),$e=new Map,ye=e=>e&&typeof e===_?E[e]??M(4,`Unknown blend preset '${e}'. Use ${T(E).map(e=>`'${e}'`).join(", ")} or a GPUBlendState object.`):e||0,be=(e,t,r={},a=0)=>(m.defines.replace(/([^\s,]+)[ \t]+([^,\n]+)/g,(e,t,a)=>r[t]=a.trim()),(a=T(r)).length&&(e=e.replace(new RegExp(`\\b(?:${a.sort((e,t)=>t.length-e.length).join("|")})\\b`,"g"),e=>r[e])),o.createShaderModule({code:e})),ve=(e,t)=>({module:e,entryPoint:t}),xe=(e={},t={},r={})=>{let a=r.group??0,o=r.structName??"Uniforms",s=r.varName??"UB",u=r.readOnly??[],f=r.$??!0,l=r.startBinding??0,c=e=>`@group(${a}) @binding(${e}) var`,d=(e,t)=>n(e/t)*t,p=0,g=()=>0,w="",h=B(e);if(h.length>0){let e=0,t={},r=h.map(([r,a],n,o,[i,s,u,f]=((e,t,r,a)=>/^[fiu]32$/.test(e)?[1,1,1,0]:(t=/^vec([234])<[fiu]32>$/.exec(e))?[r=+t[1],3===r?4:r,r,0]:(t=/^mat([234])x([234])$/.exec(e))?[(r=+t[1])*(3===(a=+t[2])?4:a),3===a?4:a,r*a,3===a?3:0]:M(12,`Unknown uniform type size for: ${e}`))(a))=>(e=d(e,s),t[r]={S:e,_:u,G:f,s:a},e+=i,` ${r}: ${a},`));e=d(e,4);let a=4*e;p=m.createUniformBuffer(a);let n=new L(a),u=new Float32Array(n),$=new Int32Array(n),y=new Uint32Array(n);S(t).forEach(e=>(e.f=/u32/.test(e.s),e.m=/i32/.test(e.s),e.A=e.f?y:e.m?$:u)),g=e=>{for(let[r,a]of B(e)){let e=t[r];e||M(13,`Unknown uniform '${r}'. Declared: ${T(t).join(", ")||"(none)"}.`);let n=Array.isArray(a)||L.isView(a)?a:[a];for(let t=0;t>>0:e.m?0|r:r,e.A[e.S+(e.G?4*(t/3|0)+t%3:t)]=r}}i?Z(p,0,n,D(n)):m.writeUniforms(p,n)},f&&(w=`struct ${o} {\n${r.join("\n")}\n}\n${c(l)} ${s}: ${o};`,l++)}let $={};return{wgsl:[w,...B(t).map(([e,t],r,a,n=l++,o=t.startsWith("texture_"),i=t.startsWith("sampler"),s=!(o||i),f,m)=>(f="",m=t,s&&!t.startsWith("array<")&&(/^struct\s+/.test(t)?(f=t.trim(),m=t.match(/^struct\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1]??t):f=`struct ${m=`${e}_buf`} {\n value: ${t},\n}`),$[e]={F:n,s:t,c:s,T:o,P:i},(f&&`${f}\n`)+`${c(n)}${s?``:""} ${e}: ${m};`))].filter(Boolean).join("\n"),uniformBuffer:p,uniformWrite:g,I:e=>B($).map(([t,r],a,n,o=e[t])=>o&&{binding:r.F,resource:r.T?ue(o):r.c?{buffer:o}:o}).filter(Boolean),E:T(e),v:T(t),D:$,B:h.length&&f?r.startBinding??0:-1}},Be=({code:e,uniforms:t={},resources:r={},format:a=m.format,u:s=!1,blend:f,wg:l,d:d,topology:p="triangle-list",count:g=3,instances:w=1,readOnly:h=[],depth:$})=>{let y=$&&!s?{format:"depth24plus",compare:"less",write:!0,...!0===$?{}:$}:0,b=e.replace(/\/\*[\s\S]*?\*\//g,"").replace(/\/\/.*/g,""),v=e=>new RegExp(`\\b${e}\\b`).test(b),x=P(B(r).filter(([e])=>v(e))),S=v("UB"),k=xe(t,x,{group:0,startBinding:0,$:S,readOnly:h}),{v:A,D:F,B:C,uniformWrite:_}=k,z=m.makeShader(k.wgsl?`${k.wgsl}\n${e}`:e),O=s?(e=>o.createComputePipeline({layout:"auto",compute:ve(e,"main")}))(z):((e,t,r,a,n)=>(a=ye(a),o.createRenderPipeline({layout:"auto",vertex:ve(e,"vs_main"),fragment:U(ve(e,"fs_main"),{targets:[t].flat().map(e=>a?{format:e,blend:a}:{format:e})}),primitive:{topology:r},...n?{depthStencil:{format:n.format,depthWriteEnabled:n.write,depthCompare:n.compare}}:{}})))(z,a,p,f,y),E=0,R=0,L=new Map,W=(e,t,a,n)=>(t={...R},B(e??{}).forEach(([e,a])=>(e in r||M(22,`Unknown resource '${e}'. Declared: ${T(r).join(", ")||"(none)"}.`),e in F&&(t[e]=F[e].c&&"function"==typeof a?.b?.mapAsync?a.b:a))),E&&R&&A.every(e=>t[e]===R[e])||(R=t,a=A.map(e=>t[e]?(e=>ae.get(e)??(ae.set(e,++ne),ne))(t[e]):0).join(","),(n=L.get(a))?E=n:(E=m.bindGroup(O,0,[...C>=0?[{binding:0,resource:{buffer:k.uniformBuffer}}]:[],...k.I(t)]),L.size>=8&&L.delete(L.keys().next().value),L.set(a,E))));C>=0&&!A.length&&W({});let D=C>=0||A.length>0,I=e=>(!E&&D&&M(15,`No resources bound. Call setResources({ ${A.join(", ")} }) before dispatching/drawing.`),e.setPipeline(O),E&&e.setBindGroup(0,E)),q=(e,t)=>!u||t&&t!==i?j(t,(t,r=t.beginComputePass())=>(I(r),e(r),r.end())):(c!==I&&(I(u),c=I),e(u)),V={pipeline:O,set uniforms(e){_(e)},setUniform:(e,t)=>_({[e]:t}),setUniforms:e=>_(e),set resources(e){W(e)},setResources:e=>W(e),...{get uniformFields(){return k.E},get resourceFields(){return A}},count:g,instances:w},H=(e=1,t=1,r=1,a)=>q(a=>a.dispatchWorkgroups(e,t,r),a);return U(V,s?{dispatch:H,run:(e=1,t=1,r=1,a)=>H(...[e,t,r].map((e,t)=>n(e/l[t])),a),dispatchIndirect:(e,t=0,r)=>q(r=>r.dispatchWorkgroupsIndirect(e.b??e,t),r),bindTo:(e=u)=>(e||M(17,"No active compute pass. Call G.beginCompute() first, or pass one."),I(e),e===u&&(c=I))}:{drawTo:(e,t,r)=>{let a=Array.isArray(t)?t:[0,0,0,1],n="load"===t?"load":"clear",o={r:a[0],g:a[1],b:a[2],a:a[3]},s=d?d.map(t=>ue(e?.[t]||M(18,`drawTo: no target for '${t}'. Pass { ${d.join(", ")} }.`))):[e?ue(e):fe()];j(r,(e,t)=>(e===i&&u&&G(),t=e.beginRenderPass({colorAttachments:s.map(e=>({view:e,loadOp:n,storeOp:"store",clearValue:o})),...y?{depthStencilAttachment:{view:y.texture?ue(y.texture):ce(s[0],y.format),depthClearValue:1,depthLoadOp:n,depthStoreOp:"store"}}:{}}),I(t),t.draw(V.count,V.instances,0,0),t.end()))}})};return U(m,{makeSchema:xe,_resolveBlend:ye,_texelBytes:me,U:J,_acquireStaging:e=>J.get(e)?.pop()??W({size:e,usage:1|p}),_releaseStaging:(e,t=J.get(e.size)??[])=>t.length<4?(t.push(e),J.set(e.size,t)):e.destroy()}),m}; +/*! TinyWebGPU 0.5.0 | MIT | github.com/lampmaker/tinywebgpu */let e=new WeakMap;export let WEBGPU=()=>{let{min:t,max:r,round:a,clz32:n,ceil:i}=Math,o=0,s=0,u=0,l=0,f=0,c=!1,m=0,d=0,p={get device(){return o},set device(e){o=e},context:0,format:0,features:0,debug:!1,defines:"",onDeviceLost:0,pipelineCacheMax:48,pipelineCacheChars:24e6,init:async(e=0,t={},r=navigator.gpu,a,n,i,o)=>(r||L(1,"WebGPU not supported"),typeof e===O&&(e=document.querySelector(a=e)||L(23,`init: nothing matches '${a}'.`)),e?.getContext&&(e=e.getContext("webgpu")),(a=await r.requestAdapter())||L(2,"No GPU adapter"),i={...t.limits},["maxStorageBufferBindingSize","maxBufferSize","maxStorageBuffersPerShaderStage"].forEach(e=>i[e]??=a.limits[e]),n=t.features??[],(i=await a.requestDevice({requiredLimits:i,requiredFeatures:n.filter(e=>a.features.has(e))})).lost.then(e=>"destroyed"!==e.reason&&p.onDeviceLost&&p.onDeviceLost(e)),o=r.getPreferredCanvasFormat(),e&&e.configure({device:i,format:o,alphaMode:t.alphaMode??"opaque",usage:t.canvasUsage??T}),C(p,{device:i,context:e,format:o,features:i.features})),destroy:()=>(N(),s=0,Y(),Q.t.forEach(e=>e.n.destroy()),Q.t=[],Q.i=0,X.forEach(e=>e.forEach(e=>e.destroy())),X.clear(),me.forEach(e=>e.destroy()),me.clear(),be.clear(),ne.clear(),o&&e.delete(o),o&&o.destroy(),o=0),makeFrag:(e,t,r,a={})=>p.makeDraw({...a,uniforms:t,resources:r,code:`struct VSOut {${_}position) pos: ${F}, @location(0) uv: ${A}};\n@vertex fn vs_main(${_}vertex_index) i: u32) -> VSOut {\nvar ndc = array<${A},3>(${A}(-1.,-3.),${A}(-1.,1.),${A}(3.,1.))[i];\nvar o: VSOut; o.pos = ${F}(ndc, 0.0, 1.0); o.uv = ndc * 0.5 + ${A}(0.5); return o;\n}\n${e}\n@fragment fn fs_main(vs: VSOut) -> ${a.targets?"FSOut":`@location(0) ${F}`} { return frag(vs.uv); }\n`.trim()}),makeDraw:(e,t)=>(t=e.targets?S(e.targets):0,Pe({...e,u:!1,h:t,code:t?`struct FSOut {${t.map((e,t)=>`@location(${t}) ${e}: ${F}`).join(", ")}};\n${e.code}`:e.code,format:t?U(e.targets):e.format})),makeCompute:(e,t,r,a,{wg:n=[8,8,1],...i}={})=>Pe({...i,code:`${e}\n@compute @workgroup_size(${n=[n[0]??1,n[1]??1,n[2]??1]})\nfn main(${_}global_invocation_id) gid: ${M}, ${_}local_invocation_id) lid: ${M}, ${_}workgroup_id) wid: ${M}) {\n${t}\n}\n`.trim(),uniforms:r,resources:a,u:!0,wg:n}),makeQuad:({frag:e,uniforms:t,resources:r,clear:a,...n},i)=>(i=p.makeFrag(e,t,r,n),C(i,{run:(e={},t=ce())=>(e&&S(e).length&&(i.uniforms=e),i.drawTo(t,a))})),makeCompute2D:({body:e,decls:t="",uniforms:r,resources:a,size:n=[1,1],wg:i,...o},s,u)=>(s=p.makeCompute(t,e,r,a,{wg:i,...o}),u=s.run,C(s,{run:(e=n[0],t=n[1],r=1,a)=>u(e,t,r,a)})),beginFrame:(e={})=>{if(N(),s)try{p.endFrame()}catch{s=0}s=V(!1),Y(e.view)},endFrame:e=>s&&(N(),K(),e=s,s=0,Y(),j(e)),frame:(e,t)=>{p.beginFrame(t);try{return e()}finally{p.endFrame()}},beginCompute:(e={})=>l||(s||(s=V(!1),c=!0),m=0,l=s.beginComputePass(f=e)),endCompute:()=>(N(),c&&p.endFrame()),timePasses:e=>{d=e||0},createStorageBuffer:(e,t,r,a,n,i)=>(r=p.createBuffer((t="number"==typeof e?0:e)?q(t):e,y|g|w,"storage"),n=(a=r.b).size,i=(e=n,t=0,r=W)=>("function"==typeof e&&([r,e]=[e,n]),ee(e,(e,r,n)=>e.copyBufferToBuffer(a,t,r,0,n),!1).then(e=>r?new r(e):e)),t&&r.w(t),C(r,{r:i,clear:()=>s?Z(()=>s.clearBuffer(a,0,n)):G(e=>e.clearBuffer(a,0,n),!1),read:i})),createBuffer:(e,t,r="buffer",a,n)=>({b:a=I({size:e=e+3&-4,usage:t}),w:n=$e(a),buffer:a,write:n}),createUniformBuffer:e=>I({size:e,usage:h|w}),createIndirectBuffer:()=>p.createBuffer(12,$|y|w),writeUniforms:(e,t,r=0)=>o.queue.writeBuffer(e,r,t.buffer??t,t.byteOffset??0,q(t)),createTexture:(e,t,r="rgba8unorm",a)=>te(e,t,r,(a="number"==typeof a?{usage:a}:a??{}).usage??T|x|b|v,"texture",!0===a.mips?re(e,t):a.mips||1),createStorageTexture:(e,t,r="rgba32float",a)=>te(e,t,r,a??x|B|b|v,"storageTexture"),createSampler:({magFilter:e="nearest",minFilter:t="nearest",wrapU:r=z,wrapV:a=z,wrapW:n,...i}={})=>o.createSampler({magFilter:e,minFilter:t,addressModeU:r,addressModeV:a,...n&&{addressModeW:n},...i}),writeTexture:(e,t,r={},a,n)=>(a=he(e,r),n=pe(e.format),r.bytesPerRow||n||L(9,`writeTexture: ${ge(e.format)}; pass bytesPerRow explicitly.`),o.queue.writeTexture(we(e,r),t,{bytesPerRow:r.bytesPerRow??a[0]*n,rowsPerImage:a[1]},ye(...a)),e),loadTexture:async(e,t={},r=e,a=0,n,i,s)=>(typeof e===O?((a=await fetch(e)).ok||L(20,`loadTexture: HTTP ${a.status} for '${e}'.`),a=await a.blob()):e instanceof Blob?a=e:typeof HTMLImageElement<"u"&&e instanceof HTMLImageElement&&(e.complete||await e.decode(),a=e),a&&(r=await createImageBitmap(a)),i=r.videoHeight||r.displayHeight||r.height,(n=r.videoWidth||r.displayWidth||r.width)&&i||L(10,"loadTexture: could not determine source dimensions."),s=t.texture??p.createTexture(n,i,t.format,{usage:t.usage,mips:t.mips}),o.queue.copyExternalImageToTexture({source:r,flipY:t.flipY??!1},{texture:s,premultipliedAlpha:t.premultipliedAlpha??!1,colorSpace:t.colorSpace??"srgb"},ye(n,i)),r!==e&&"function"==typeof r.close&&r.close(),t.mips&&p.generateMipmaps(s),s),readTexture:async(e,t={})=>{let[r,a]=he(e,t),n=pe(e.format);n||L(11,`readTexture: ${ge(e.format)}.`);let i=r*n,o=i+255&-256,s=new W(await ee(o*a,(n,i)=>n.copyTextureToBuffer(we(e,t),{buffer:i,bytesPerRow:o,rowsPerImage:a},ye(r,a)),!1)),u=new W(i*a);for(let e=0;e{let t=ne.get(e.format);(!t||t.o!==p.defines)&&ne.set(e.format,t={o:p.defines,p:p.makeFrag(`fn frag(uv: ${A}) -> ${F} { return textureSample(src, samp, ${A}(uv.x, 1.0 - uv.y)); }`,{},{src:"texture_2d",samp:"sampler"},{format:e.format})});let r=t.p;ae||=p.createSampler({magFilter:"linear",minFilter:"linear"});let a=!s,n=t=>e.createView({baseMipLevel:t,mipLevelCount:1});a&&p.beginFrame();for(let t=1;tt={read:e(),write:e(),swap:()=>[t.read,t.write]=[t.write,t.read]},createPingPong:e=>p.pingPong(()=>p.createStorageBuffer(e)),createPingPongTexture:(e,t,r="rgba16float",a)=>p.pingPong(()=>p.createStorageTexture(e,t,r,a)),resizeCanvas:(e=p.context?.canvas,n={},i,s,u,l)=>(e||L(8,"resizeCanvas: no canvas — init() with a context, or pass one."),s=(i=(e,i)=>r(1,t(o?o.limits.maxTextureDimension2D:1/0,a((e||i)*(n.dpr??globalThis.devicePixelRatio??1)))))(e.clientWidth,e.width),u=i(e.clientHeight,e.height),(l=e.width!==s||e.height!==u)&&(e.width=s,e.height=u),{...ye(s,u),changed:l}),show:(e,t,r={},a=r.format??t?.format??p.format,n,i,o,s)=>(n=/uint$/.test(e.format)?"u32":/sint$/.test(e.format)?"i32":"f32",(!(o=be.get(i=`${a}|${n}|${r.flipY?1:0}`))||o.o!==p.defines)&&(s=p.makeFrag(`fn frag(uv: ${A}) -> ${F} {\n let d = ${A}(textureDimensions(src));\n let c = vec2(clamp(${A}(uv.x, ${r.flipY?"uv.y":"1.0 - uv.y"}) * d, ${A}(0.0), d - 1.0));\n return ${F}(textureLoad(src, c, 0)) * UB.scale + UB.offset;\n }`,{scale:F,offset:F},{src:`texture_2d<${n}>`},{format:a}),be.set(i,o={o:p.defines,p:s})),(s=o.p).setResources({src:e}),s.setUniforms({scale:r.scale??[1,1,1,1],offset:r.offset??[0,0,0,0]}),s.drawTo(t,r.clear),s),save:async(e,t="capture.png",r={})=>{/^(rgba|bgra)8unorm(-srgb)?$/.test(e.format)||L(19,`save: needs an 8-bit RGBA texture, got '${e.format}'. Render it into an rgba8unorm target first.`);let[a,n]=he(e,r),i=await p.readTexture(e,{...r,Ctor:W});if(e.format.startsWith("bgra"))for(let e=0;eURL.revokeObjectURL(e.href),1e4)}return s},makeShader:e=>xe(e),bindGroup:(e,t,r)=>o.createBindGroup({layout:e.getBindGroupLayout(t),entries:r})},g=4,w=8,h=64,y=128,$=256,b=1,v=2,x=4,B=8,T=16,{entries:P,keys:S,assign:C,values:U,fromEntries:k}=Object,A="vec2",F="vec4",M="vec3",_="@builtin(",O="string",z="clamp-to-edge",R=(e,t,r=e=>({srcFactor:e,dstFactor:t,operation:"add"}))=>({color:r(e),alpha:r("one")}),E={alpha:R("src-alpha","one-minus-src-alpha"),premultiplied:R("one","one-minus-src-alpha"),additive:R("one","one")},L=(e,t)=>{throw Error(t||e)},W=Uint8Array,D=ArrayBuffer,I=e=>o.createBuffer(e),q=e=>e.byteLength,V=e=>o.createCommandEncoder(),j=e=>o.queue.submit([e.finish()]),G=(e,t,r=V(t))=>(e(r),j(r)),H=(e,t)=>e||s?t(e||s):G(t),N=()=>{try{l&&l.end()}catch{}l=0},Y=e=>(u=e,l=f=m=0,c=!1),Z=(e,t=l)=>(N(),e(),t&&(l=s.beginComputePass(f||{}),m&&m(l))),Q={t:[],i:0,$:1<<18},J=(e,t,a,n)=>{let i=n+3&-4;for(;Q.t[Q.i]&&Q.t[Q.i].n.size-Q.t[Q.i].en&&o.l.fill(0,o.e+n,o.e+i);let l=o.e;Z(()=>s.copyBufferToBuffer(o.n,l,e,t,i)),o.e+=i},K=()=>(Q.t.forEach(e=>(e.e&&o.queue.writeBuffer(e.n,0,e.l,0,e.e),e.e=0)),Q.i=0),X=new Map,ee=async(e,t,r,a,n,i)=>(n=p._acquireStaging(a=e+3&-4),G(e=>t(e,n,a),r),await n.mapAsync(1),i=n.getMappedRange(0,a).slice(0,e),n.unmap(),p._releaseStaging(n),i),te=(e,t,r,a,n,i=1)=>o.createTexture({size:ye(e,t),format:r,mipLevelCount:i,sampleCount:1,usage:a}),re=(e,t)=>32-n(r(e,t)),ae=0,ne=new Map,ie=new WeakMap,oe=0,se=new WeakMap,ue=new WeakMap,le=(e,t=se.get(e))=>t||(se.set(e,t=e.createView()),ue.set(t,[e.width,e.height]),t),fe=e=>e.createView?le(e):e,ce=(e=u||p.context&&le(p.context.getCurrentTexture()))=>(e||L(7,"No render target: init() was called without a canvas context — pass an explicit view."),s&&(u=e),e),me=new Map,de=(e,t,r=ue.get(e)||L(21,"depth: target size unknown. Pass a GPUTexture (not a view) to drawTo, or supply depth.texture."),a=`${r[0]}x${r[1]} ${t}`,n)=>le(me.get(a)||(me.size>=4&&(n=me.keys().next().value,me.get(n).destroy(),me.delete(n)),me.set(a,n=te(r[0],r[1],t,T,"depth")),n)),pe=e=>{let t=/^(r|rg|rgba|bgra)(8|16|32)/.exec(e);return t?t[1].length*(t[2]/8):{rgb10a2unorm:4,rgb10a2uint:4,rg11b10ufloat:4}[e]},ge=e=>`unknown bytes-per-texel for format '${e}'`,we=(e,t)=>({texture:e,mipLevel:t.mipLevel??0,origin:{x:t.x??0,y:t.y??0}}),he=(e,t)=>[t.width??e.width,t.height??e.height],ye=(e,t)=>({width:e,height:t}),$e=e=>(t,r=0,a)=>(D.isView(t)||t instanceof D||L(5,"buffer write: expected a TypedArray or ArrayBuffer."),3&r&&L(6,`buffer write: byteOffset must be a multiple of 4 (got ${r}).`),s?J(e,r,t,q(t)):(3&q(t)&&((a=new W(q(t)+3&-4)).set(D.isView(t)?new W(t.buffer,t.byteOffset,q(t)):new W(t)),t=a),p.writeUniforms(e,t,r))),be=new Map,ve=e=>e&&typeof e===O?E[e]??L(4,`Unknown blend preset '${e}'. Use ${S(E).map(e=>`'${e}'`).join(", ")} or a GPUBlendState object.`):e||0,xe=(e,t,r={},a=0)=>(p.defines.replace(/([^\s,]+)[ \t]+([^,\n]+)/g,(e,t,a)=>r[t]=a.trim()),(a=S(r)).length&&(e=e.replace(new RegExp(`\\b(?:${a.sort((e,t)=>t.length-e.length).join("|")})\\b`,"g"),e=>r[e])),o.createShaderModule({code:e})),Be=(e,t)=>({module:e,entryPoint:t}),Te=(e={},t={},r={})=>{let a=r.group??0,n=r.structName??"Uniforms",o=r.varName??"UB",u=r.readOnly??[],l=r.S??!0,f=r.startBinding??0,c=e=>`@group(${a}) @binding(${e}) var`,m=(e,t)=>i(e/t)*t,d=0,g=()=>0,w="",h=P(e);if(h.length>0){let e=0,t={},r=h.map(([r,a],n,i,[o,s,u,l]=((e,t,r,a)=>/^[fiu]32$/.test(e)?[1,1,1,0]:(t=/^vec([234])<[fiu]32>$/.exec(e))?[r=+t[1],3===r?4:r,r,0]:(t=/^mat([234])x([234])$/.exec(e))?[(r=+t[1])*(3===(a=+t[2])?4:a),3===a?4:a,r*a,3===a?3:0]:L(12,`Unknown uniform type size for: ${e}`))(a))=>(e=m(e,s),t[r]={_:e,G:u,A:l,s:a},e+=o,` ${r}: ${a},`));e=m(e,4);let a=4*e;d=p.createUniformBuffer(a);let i=new D(a),u=new Float32Array(i),y=new Int32Array(i),$=new Uint32Array(i);U(t).forEach(e=>(e.f=/u32/.test(e.s),e.c=/i32/.test(e.s),e.F=e.f?$:e.c?y:u)),g=e=>{for(let[r,a]of P(e)){let e=t[r];e||L(13,`Unknown uniform '${r}'. Declared: ${S(t).join(", ")||"(none)"}.`);let n=Array.isArray(a)||D.isView(a)?a:[a];for(let t=0;t>>0:e.c?0|r:r,e.F[e._+(e.A?4*(t/3|0)+t%3:t)]=r}}s?J(d,0,i,q(i)):p.writeUniforms(d,i)},l&&(w=`struct ${n} {\n${r.join("\n")}\n}\n${c(f)} ${o}: ${n};`,f++)}let y={};return{wgsl:[w,...P(t).map(([e,t],r,a,n=/^read\s+/.test(t),i=/^uniform\s+/.test(t),o=t.replace(/^(read|uniform)\s+/,""),s=f++,l=o.startsWith("texture_"),m=o.startsWith("sampler"),d=!(l||m),p,g)=>(p="",g=o,d&&!o.startsWith("array<")&&(/^struct\s+/.test(o)?(p=o.trim(),g=o.match(/^struct\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1]??o):p=`struct ${g=`${e}_buf`} {\n value: ${o},\n}`),y[e]={T:s,s:o,m:d,R:d&&i,I:l,M:m},(p&&`${p}\n`)+`${c(s)}${d?i?"":``:""} ${e}: ${g};`))].filter(Boolean).join("\n"),uniformBuffer:d,uniformWrite:g,E:e=>P(y).map(([t,r],a,n,i=e[t])=>i&&{binding:r.T,resource:r.I?fe(i):r.m?{buffer:i}:i}).filter(Boolean),D:S(e),v:S(t),P:y,B:h.length&&l?r.startBinding??0:-1}},Pe=({code:t,uniforms:r={},resources:a={},format:n=p.format,u:u=!1,blend:f,wg:c,h:g,topology:w="triangle-list",count:h=3,instances:y=1,readOnly:$=[],depth:b,async:v=!1,cache:x=!0,moduleOnly:B=!1})=>{let T=b&&!u?{format:"depth24plus",compare:"less",write:!0,...!0===b?{}:b}:0,U=t.replace(/\/\*[\s\S]*?\*\//g,"").replace(/\/\/.*/g,""),A=e=>new RegExp(`\\b${e}\\b`).test(U),F=k(P(a).filter(([e])=>A(e))),M=A("UB"),_=Te(r,F,{group:0,startBinding:0,S:M,readOnly:$}),{v:O,P:z,B:R,uniformWrite:E}=_,W=_.wgsl?`${_.wgsl}\n${t}`:t;if(B)return{wgsl:W,module:p.makeShader(W)};let D,I=0,q=0,V=e=>(I=e,q&&(X(q),q=0),e),j=o&&u&&x?`C|main|${p.defines}|${W}`:0,G=j&&(e.get(o)||(e.set(o,new Map),e.get(o))),Y=j&&G.get(j);Y&&(G.delete(j),G.set(j,Y));let Z=0;if(Y&&Y.d)Z=1,I=Y.d,D=Promise.resolve(I);else if(Y&&v)Z=1,D=Y.U.then(V);else{let e=p.makeShader(W),t=u?(e=>({layout:"auto",compute:Be(e,"main")}))(e):((e,t,r,a,n)=>(a=ve(a),{layout:"auto",vertex:Be(e,"vs_main"),fragment:C(Be(e,"fs_main"),{targets:[t].flat().map(e=>a?{format:e,blend:a}:{format:e})}),primitive:{topology:r},...n?{depthStencil:{format:n.format,depthWriteEnabled:n.write,depthCompare:n.compare}}:{}}))(e,n,w,f,T);if(v){let e,r=u?o.createComputePipelineAsync(t):o.createRenderPipelineAsync(t);j&&(((e,t=0)=>{G.delete(j),G.set(j,e);for(let e of G.keys())t+=e.length;for(let e of G.keys()){if(G.size<=p.pipelineCacheMax&&t<=p.pipelineCacheChars)break;t-=e.length,G.delete(e)}})(e={U:r}),r.then(t=>{e.d=t},()=>{G.get(j)===e&&G.delete(j)})),D=r.then(V,e=>{throw e})}else I=u?o.createComputePipeline(t):o.createRenderPipeline(t),D=Promise.resolve(I)}let Q=0,J=0,K=new Map,X=(e,t,r,n)=>(t={...J},P(e??{}).forEach(([e,r])=>(e in a||L(22,`Unknown resource '${e}'. Declared: ${S(a).join(", ")||"(none)"}.`),e in z&&(t[e]=z[e].m&&"function"==typeof r?.b?.mapAsync?r.b:r))),Q&&J&&O.every(e=>t[e]===J[e])||(J=t,r=O.map(e=>t[e]?(e=>ie.get(e)??(ie.set(e,++oe),oe))(t[e]):0).join(","),(n=K.get(r))?Q=n:(Q=p.bindGroup(I,0,[...R>=0?[{binding:0,resource:{buffer:_.uniformBuffer}}]:[],..._.E(t)]),K.size>=8&&K.delete(K.keys().next().value),K.set(r,Q)))),ee=e=>{I?X(e):q={...q||{},...e||{}}};R>=0&&!O.length&&ee({});let te=R>=0||O.length>0,re=()=>(!I&&L(24,"Pipeline is still compiling. This pipeline was made with { async: true }; await its `ready` promise before dispatching/drawing."),!Q&&te&&L(15,`No resources bound. Call setResources({ ${O.join(", ")} }) before dispatching/drawing.`)),ae=e=>(re(),e.setPipeline(I),Q&&e.setBindGroup(0,Q)),ne=(e,t)=>!l||t&&t!==s?H(t,(t,r)=>(re(),r=t.beginComputePass(d?d(se):void 0),ae(r),e(r),r.end())):(m!==ae&&(ae(l),m=ae),e(l)),se={get pipeline(){return I},ready:D,get wgsl(){return W},get cached(){return!!Z},set uniforms(e){E(e)},setUniform:(e,t)=>E({[e]:t}),setUniforms:e=>E(e),set resources(e){ee(e)},setResources:e=>ee(e),...{get uniformFields(){return _.D},get resourceFields(){return O}},count:h,instances:y},ue=(e=1,t=1,r=1,a)=>ne(a=>a.dispatchWorkgroups(e,t,r),a);return C(se,u?{dispatch:ue,run:(e=1,t=1,r=1,a)=>ue(...[e,t,r].map((e,t)=>i(e/c[t])),a),dispatchIndirect:(e,t=0,r)=>ne(r=>r.dispatchWorkgroupsIndirect(e.b??e,t),r),bindTo:(e=l)=>(e||L(17,"No active compute pass. Call G.beginCompute() first, or pass one."),ae(e),e===l&&(m=ae))}:{drawTo:(e,t,r)=>{let a=Array.isArray(t)?t:[0,0,0,1],n="load"===t?"load":"clear",i={r:a[0],g:a[1],b:a[2],a:a[3]},o=g?g.map(t=>fe(e?.[t]||L(18,`drawTo: no target for '${t}'. Pass { ${g.join(", ")} }.`))):[e?fe(e):ce()];H(r,(e,t)=>(re(),e===s&&l&&N(),t=e.beginRenderPass({colorAttachments:o.map(e=>({view:e,loadOp:n,storeOp:"store",clearValue:i})),...T?{depthStencilAttachment:{view:T.texture?fe(T.texture):de(o[0],T.format),depthClearValue:1,depthLoadOp:n,depthStoreOp:"store"}}:{}}),ae(t),t.draw(se.count,se.instances,0,0),t.end()))}})};return C(p,{makeSchema:Te,_resolveBlend:ve,_texelBytes:pe,O:X,_acquireStaging:e=>X.get(e)?.pop()??I({size:e,usage:1|w}),_releaseStaging:(e,t=X.get(e.size)??[])=>t.length<4?(t.push(e),X.set(e.size,t)):e.destroy()}),p}; diff --git a/dist/tinywebgpu.tiny.js b/dist/tinywebgpu.tiny.js index a925685..eb81273 100644 --- a/dist/tinywebgpu.tiny.js +++ b/dist/tinywebgpu.tiny.js @@ -1,2 +1,2 @@ -/*! TinyWebGPU 0.4.0 | MIT | github.com/lampmaker/tinywebgpu */export let WEBGPU=()=>{let[$a,$b,$c,$d]="beginComputePass,clearBuffer,format,targets".split(",");let{max:e,clz32:r,ceil:t}=Math,a=0,n=0,i=0,o=0,s=0,u=!1,f=0,c={get device(){return a},set device(e){a=e},context:0,[$c]:0,features:0,debug:!1,defines:"",onDeviceLost:0,init:async(e=0,r={},t=navigator.gpu,a,n,i,o)=>(t||V(1,!1),typeof e===A&&(e=document.querySelector(a=e)||V(23,!1)),e?.getContext&&(e=e.getContext("webgpu")),(a=await t.requestAdapter())||V(2,!1),i={...r.limits},["maxStorageBufferBindingSize","maxBufferSize"].forEach(e=>i[e]??=a.limits[e]),n=r.features??[],(i=await a.requestDevice({requiredLimits:i,requiredFeatures:n.filter(e=>a.features.has(e))})).lost.then(e=>"destroyed"!==e.reason&&c.onDeviceLost&&c.onDeviceLost(e)),o=t.getPreferredCanvasFormat(),e&&e.configure({device:i,[$c]:o,alphaMode:r.alphaMode??"opaque",usage:r.canvasUsage??v}),x(c,{device:i,context:e,[$c]:o,features:i.features})),destroy:()=>(L(),n=0,j(),a&&a.destroy(),a=0),makeFrag:(e,r,t,a={})=>c.makeDraw({...a,uniforms:r,resources:t,code:`struct VSOut {${U}position) pos: ${F}, @location(0) uv: ${_}};\n@vertex fn vs_main(${U}vertex_index) i: u32) -> VSOut {\nvar ndc = array<${_},3>(${_}(-1.,-3.),${_}(-1.,1.),${_}(3.,1.))[i];\nvar o: VSOut; o.pos = ${F}(ndc, 0.0, 1.0); o.uv = ndc * 0.5 + ${_}(0.5); return o;\n}\n${e}\n@fragment fn fs_main(vs: VSOut) -> ${a[$d]?"FSOut":`@location(0) ${F}`} { return frag(vs.uv); }\n`.trim()}),makeDraw:(e,r)=>(r=e[$d]?B(e[$d]):0,ie({...e,t:!1,o:r,code:r?`struct FSOut {${r.map((e,r)=>`@location(${r}) ${e}: ${F}`).join(", ")}};\n${e.code}`:e.code,[$c]:r?S(e[$d]):e[$c]})),makeCompute:(e,r,t,a,{wg:n=[8,8,1]}={})=>ie({code:`${e}\n@compute @workgroup_size(${n=[n[0]??1,n[1]??1,n[2]??1]})\nfn main(${U}global_invocation_id) gid: ${C}, ${U}local_invocation_id) lid: ${C}, ${U}workgroup_id) wid: ${C}) {\n${r}\n}\n`.trim(),uniforms:t,resources:a,t:!0,wg:n}),makeQuad:({frag:e,uniforms:r,resources:t,clear:a,...n},i)=>(i=c.makeFrag(e,r,t,n),x(i,{run:(e={},r=X())=>(e&&B(e).length&&(i.uniforms=e),i.drawTo(r,a))})),makeCompute2D:({body:e,decls:r="",uniforms:t,resources:a,size:n=[1,1],wg:i},o,s)=>(o=c.makeCompute(r,e,t,a,{wg:i}),s=o.run,x(o,{run:(e=n[0],r=n[1],t=1,a)=>s(e,r,t,a)})),beginFrame:(e={})=>{if(L(),n)try{c.endFrame()}catch{n=0}n=q(!1),j(e.view)},endFrame:e=>n&&(L(),e=n,n=0,j(),G(e)),frame:(e,r)=>{c.beginFrame(r);try{return e()}finally{c.endFrame()}},beginCompute:(e={})=>o||(n||(n=q(!1),u=!0),f=0,o=n[$a](s=e)),endCompute:()=>(L(),u&&c.endFrame()),createStorageBuffer:(e,r,t,a,i,o)=>(t=c.createBuffer((r="number"==typeof e?0:e)?E(r):e,g|m|l,"storage"),i=(a=t.b).size,r&&t.w(r),x(t,{r:void 0,clear:()=>n?R(()=>n[$b](a,0,i)):T(e=>e[$b](a,0,i),!1)})),createBuffer:(e,r,t="buffer",a,n)=>({b:a=P({size:e=e+3&-4,usage:r}),w:ee(a)}),createUniformBuffer:e=>P({size:e,usage:d|l}),createIndirectBuffer:()=>c.createBuffer(12,p|g|l),writeUniforms:(e,r,t=0)=>a.queue.writeBuffer(e,t,r.buffer??r,r.byteOffset??0,E(r)),createTexture:(e,r,t="rgba8unorm",a)=>I(e,r,t,(a="number"==typeof a?{usage:a}:a??{}).usage??v|b|$|w,"texture",!0===a.mips?N(e,r):a.mips||1),createStorageTexture:(e,r,t="rgba32float",a)=>I(e,r,t,a??b|y|$|w,"storageTexture"),createSampler:({magFilter:e="nearest",minFilter:r="nearest",wrapU:t=O,wrapV:n=O,wrapW:i,...o}={})=>a.createSampler({magFilter:e,minFilter:r,addressModeU:t,addressModeV:n,...i&&{addressModeW:i},...o}),makeShader:e=>te(e),bindGroup:(e,r,t)=>a.createBindGroup({layout:e.getBindGroupLayout(r),entries:t})},m=4,l=8,d=64,g=128,p=256,$=1,w=2,b=4,y=8,v=16,{entries:h,keys:B,assign:x,values:S,fromEntries:k}=Object,_="vec2",F="vec4",C="vec3",U="@builtin(",A="string",O="clamp-to-edge",z={},V=(e,r)=>{throw Error(r||e)},W=Uint8Array,M=ArrayBuffer,P=e=>a.createBuffer(e),E=e=>e.byteLength,q=e=>a.createCommandEncoder(),G=e=>a.queue.submit([e.finish()]),T=(e,r,t=q(r))=>(e(t),G(t)),D=(e,r)=>e||n?r(e||n):T(r),L=()=>{try{o&&o.end()}catch{}o=0},j=e=>(i=e,o=s=f=0,u=!1),R=(e,r=o)=>(L(),e(),r&&(o=n[$a](s||{}),f&&f(o))),I=(e,r,t,n,i,o=1)=>a.createTexture({size:Y(e,r),[$c]:t,mipLevelCount:o,sampleCount:1,usage:n}),N=(t,a)=>32-r(e(t,a)),Z=new WeakMap,Q=0,H=new WeakMap,J=(e,r=H.get(e))=>r||(H.set(e,r=e.createView()),r),K=e=>e.createView?J(e):e,X=(e=i||c.context&&J(c.context.getCurrentTexture()))=>(e||V(7,!1),n&&(i=e),e),Y=(e,r)=>({width:e,height:r}),ee=e=>(r,t=0,a)=>(M.isView(r)||r instanceof M||V(5,!1),3&t&&V(6,!1),3&E(r)&&((a=new W(E(r)+3&-4)).set(M.isView(r)?new W(r.buffer,r.byteOffset,E(r)):new W(r)),r=a),c.writeUniforms(e,r,t)),re=e=>e&&typeof e===A?z[e]??V(4,!1):e||0,te=(e,r,t={},n=0)=>(c.defines.replace(/([^\s,]+)[ \t]+([^,\n]+)/g,(e,r,a)=>t[r]=a.trim()),(n=B(t)).length&&(e=e.replace(new RegExp(`\\b(?:${n.sort((e,r)=>r.length-e.length).join("|")})\\b`,"g"),e=>t[e])),a.createShaderModule({code:e})),ae=(e,r)=>({module:e,entryPoint:r}),ne=(e={},r={},a={})=>{let n=a.group??0,i=a.structName??"Uniforms",o=a.varName??"UB",s=a.readOnly??[],u=a.l??!0,f=a.startBinding??0,m=e=>`@group(${n}) @binding(${e}) var`,l=(e,r)=>t(e/r)*r,d=0,g=()=>0,p="",$=h(e);if($.length>0){let e=0,r={},t=$.map(([t,a],n,i,[o,s,u,f]=((e,r,t,a)=>/^[fiu]32$/.test(e)?[1,1,1,0]:(r=/^vec([234])<[fiu]32>$/.exec(e))?[t=+r[1],3===t?4:t,t,0]:(r=/^mat([234])x([234])$/.exec(e))?[(t=+r[1])*(3===(a=+r[2])?4:a),3===a?4:a,t*a,3===a?3:0]:V(12,!1))(a))=>(e=l(e,s),r[t]={f:e,u:u,c:f,e:a},e+=o,` ${t}: ${a},`));e=l(e,4);let a=4*e;d=c.createUniformBuffer(a);let n=new M(a),s=new Float32Array(n),w=new Int32Array(n),b=new Uint32Array(n);S(r).forEach(e=>(e.i=/u32/.test(e.e),e.n=/i32/.test(e.e),e.d=e.i?b:e.n?w:s)),g=e=>{for(let[t,a]of h(e)){let e=r[t];e||V(13,!1);let n=Array.isArray(a)||M.isView(a)?a:[a];for(let r=0;r>>0:e.n?0|t:t,e.d[e.f+(e.c?4*(r/3|0)+r%3:r)]=t}}c.writeUniforms(d,n)},u&&(p=`struct ${i} {\n${t.join("\n")}\n}\n${m(f)} ${o}: ${i};`,f++)}let w={};return{wgsl:[p,...h(r).map(([e,r],t,a,n=f++,i=r.startsWith("texture_"),o=r.startsWith("sampler"),u=!(i||o),c,l)=>(c="",l=r,u&&!r.startsWith("array<")&&(/^struct\s+/.test(r)?(c=r.trim(),l=r.match(/^struct\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1]??r):c=`struct ${l=`${e}_buf`} {\n value: ${r},\n}`),w[e]={m:n,e:r,s:u,h:i,v:o},(c&&`${c}\n`)+`${m(n)}${u?``:""} ${e}: ${l};`))].filter(Boolean).join("\n"),uniformBuffer:d,uniformWrite:g,_:e=>h(w).map(([r,t],a,n,i=e[r])=>i&&{binding:t.m,resource:t.h?K(i):t.s?{buffer:i}:i}).filter(Boolean),A:B(e),S:B(r),$:w,G:$.length&&u?a.startBinding??0:-1}},ie=({code:e,uniforms:r={},resources:i={},[$c]:s=c[$c],t:u=!1,blend:m,wg:l,o:d,topology:g="triangle-list",count:p=3,instances:$=1,readOnly:w=[]})=>{let b=e.replace(/\/\*[\s\S]*?\*\//g,"").replace(/\/\/.*/g,""),y=e=>new RegExp(`\\b${e}\\b`).test(b),v=k(h(i).filter(([e])=>y(e))),B=y("UB"),S=ne(r,v,{group:0,startBinding:0,l:B,readOnly:w}),{S:_,$:F,G:C,uniformWrite:U}=S,A=c.makeShader(S.wgsl?`${S.wgsl}\n${e}`:e),O=u?(e=>a.createComputePipeline({layout:"auto",compute:ae(e,"main")}))(A):((e,r,t,n)=>(n=re(n),a.createRenderPipeline({layout:"auto",vertex:ae(e,"vs_main"),fragment:x(ae(e,"fs_main"),{[$d]:[r].flat().map(e=>n?{[$c]:e,blend:n}:{[$c]:e})}),primitive:{topology:t}})))(A,s,g,m),z=0,W=0,M=new Map,P=(e,r,t,a)=>(r={...W},h(e??{}).forEach(([e,t])=>(e in i||V(22,!1),e in F&&(r[e]=F[e].s&&"function"==typeof t?.b?.mapAsync?t.b:t))),z&&W&&_.every(e=>r[e]===W[e])||(W=r,t=_.map(e=>r[e]?(e=>Z.get(e)??(Z.set(e,++Q),Q))(r[e]):0).join(","),(a=M.get(t))?z=a:(z=c.bindGroup(O,0,[...C>=0?[{binding:0,resource:{buffer:S.uniformBuffer}}]:[],...S._(r)]),M.size>=8&&M.delete(M.keys().next().value),M.set(t,z))));C>=0&&!_.length&&P({});let E=C>=0||_.length>0,q=e=>(!z&&E&&V(15,!1),e.setPipeline(O),z&&e.setBindGroup(0,z)),G=(e,r)=>!o||r&&r!==n?D(r,(r,t=r[$a]())=>(q(t),e(t),t.end())):(f!==q&&(q(o),f=q),e(o)),T={pipeline:O,set uniforms(e){U(e)},setUniform:(e,r)=>U({[e]:r}),setUniforms:e=>U(e),set resources(e){P(e)},setResources:e=>P(e),count:p,instances:$},j=(e=1,r=1,t=1,a)=>G(a=>a.dispatchWorkgroups(e,r,t),a);return x(T,u?{dispatch:j,run:(e=1,r=1,a=1,n)=>j(...[e,r,a].map((e,r)=>t(e/l[r])),n),dispatchIndirect:(e,r=0,t)=>G(t=>t.dispatchWorkgroupsIndirect(e.b??e,r),t),bindTo:(e=o)=>(e||V(17,!1),q(e),e===o&&(f=q))}:{drawTo:(e,r,t)=>{let a=Array.isArray(r)?r:[0,0,0,1],i="load"===r?"load":"clear",s={r:a[0],g:a[1],b:a[2],a:a[3]},u=d?d.map(r=>K(e?.[r]||V(18,!1))):[e?K(e):X()];D(t,(e,r)=>(e===n&&o&&L(),r=e.beginRenderPass({colorAttachments:u.map(e=>({view:e,loadOp:i,storeOp:"store",clearValue:s}))}),q(r),r.draw(T.count,T.instances,0,0),r.end()))}})};return x(c,{makeSchema:ne,_resolveBlend:re}),c}; +/*! TinyWebGPU 0.5.0 | MIT | github.com/lampmaker/tinywebgpu */let e=new WeakMap;export let WEBGPU=()=>{let[$a,$b,$c,$d]="beginComputePass,clearBuffer,format,targets".split(",");let{max:t,clz32:r,ceil:a}=Math,n=0,i=0,s=0,o=0,u=0,f=!1,l=0,c=0,m={get device(){return n},set device(e){n=e},context:0,[$c]:0,features:0,debug:!1,defines:"",onDeviceLost:0,pipelineCacheMax:48,pipelineCacheChars:24e6,init:async(e=0,t={},r=navigator.gpu,a,n,i,s)=>(r||W(1,!1),typeof e===O&&(e=document.querySelector(a=e)||W(23,!1)),e?.getContext&&(e=e.getContext("webgpu")),(a=await r.requestAdapter())||W(2,!1),i={...t.limits},["maxStorageBufferBindingSize","maxBufferSize","maxStorageBuffersPerShaderStage"].forEach(e=>i[e]??=a.limits[e]),n=t.features??[],(i=await a.requestDevice({requiredLimits:i,requiredFeatures:n.filter(e=>a.features.has(e))})).lost.then(e=>"destroyed"!==e.reason&&m.onDeviceLost&&m.onDeviceLost(e)),s=r.getPreferredCanvasFormat(),e&&e.configure({device:i,[$c]:s,alphaMode:t.alphaMode??"opaque",usage:t.canvasUsage??x}),k(m,{device:i,context:e,[$c]:s,features:i.features})),destroy:()=>(R(),i=0,I(),n&&e.delete(n),n&&n.destroy(),n=0),makeFrag:(e,t,r,a={})=>m.makeDraw({...a,uniforms:t,resources:r,code:`struct VSOut {${U}position) pos: ${F}, @location(0) uv: ${A}};\n@vertex fn vs_main(${U}vertex_index) i: u32) -> VSOut {\nvar ndc = array<${A},3>(${A}(-1.,-3.),${A}(-1.,1.),${A}(3.,1.))[i];\nvar o: VSOut; o.pos = ${F}(ndc, 0.0, 1.0); o.uv = ndc * 0.5 + ${A}(0.5); return o;\n}\n${e}\n@fragment fn fs_main(vs: VSOut) -> ${a[$d]?"FSOut":`@location(0) ${F}`} { return frag(vs.uv); }\n`.trim()}),makeDraw:(e,t)=>(t=e[$d]?S(e[$d]):0,oe({...e,t:!1,l:t,code:t?`struct FSOut {${t.map((e,t)=>`@location(${t}) ${e}: ${F}`).join(", ")}};\n${e.code}`:e.code,[$c]:t?C(e[$d]):e[$c]})),makeCompute:(e,t,r,a,{wg:n=[8,8,1],...i}={})=>oe({...i,code:`${e}\n@compute @workgroup_size(${n=[n[0]??1,n[1]??1,n[2]??1]})\nfn main(${U}global_invocation_id) gid: ${P}, ${U}local_invocation_id) lid: ${P}, ${U}workgroup_id) wid: ${P}) {\n${t}\n}\n`.trim(),uniforms:r,resources:a,t:!0,wg:n}),makeQuad:({frag:e,uniforms:t,resources:r,clear:a,...n},i)=>(i=m.makeFrag(e,t,r,n),k(i,{run:(e={},t=ee())=>(e&&S(e).length&&(i.uniforms=e),i.drawTo(t,a))})),makeCompute2D:({body:e,decls:t="",uniforms:r,resources:a,size:n=[1,1],wg:i,...s},o,u)=>(o=m.makeCompute(t,e,r,a,{wg:i,...s}),u=o.run,k(o,{run:(e=n[0],t=n[1],r=1,a)=>u(e,t,r,a)})),beginFrame:(e={})=>{if(R(),i)try{m.endFrame()}catch{i=0}i=G(!1),I(e.view)},endFrame:e=>i&&(R(),e=i,i=0,I(),T(e)),frame:(e,t)=>{m.beginFrame(t);try{return e()}finally{m.endFrame()}},beginCompute:(e={})=>o||(i||(i=G(!1),f=!0),l=0,o=i[$a](u=e)),endCompute:()=>(R(),f&&m.endFrame()),timePasses:e=>{c=e||0},createStorageBuffer:(e,t,r,a,n,s)=>(r=m.createBuffer((t="number"==typeof e?0:e)?D(t):e,h|d|g,"storage"),n=(a=r.b).size,t&&r.w(t),k(r,{r:void 0,clear:()=>i?N(()=>i[$b](a,0,n)):L(e=>e[$b](a,0,n),!1)})),createBuffer:(e,t,r="buffer",a,n)=>({b:a=q({size:e=e+3&-4,usage:t}),w:re(a)}),createUniformBuffer:e=>q({size:e,usage:p|g}),createIndirectBuffer:()=>m.createBuffer(12,$|h|g),writeUniforms:(e,t,r=0)=>n.queue.writeBuffer(e,r,t.buffer??t,t.byteOffset??0,D(t)),createTexture:(e,t,r="rgba8unorm",a)=>Z(e,t,r,(a="number"==typeof a?{usage:a}:a??{}).usage??x|b|w|y,"texture",!0===a.mips?Q(e,t):a.mips||1),createStorageTexture:(e,t,r="rgba32float",a)=>Z(e,t,r,a??b|v|w|y,"storageTexture"),createSampler:({magFilter:e="nearest",minFilter:t="nearest",wrapU:r=M,wrapV:a=M,wrapW:i,...s}={})=>n.createSampler({magFilter:e,minFilter:t,addressModeU:r,addressModeV:a,...i&&{addressModeW:i},...s}),makeShader:e=>ne(e),bindGroup:(e,t,r)=>n.createBindGroup({layout:e.getBindGroupLayout(t),entries:r})},d=4,g=8,p=64,h=128,$=256,w=1,y=2,b=4,v=8,x=16,{entries:B,keys:S,assign:k,values:C,fromEntries:_}=Object,A="vec2",F="vec4",P="vec3",U="@builtin(",O="string",M="clamp-to-edge",z={},W=(e,t)=>{throw Error(t||e)},V=Uint8Array,E=ArrayBuffer,q=e=>n.createBuffer(e),D=e=>e.byteLength,G=e=>n.createCommandEncoder(),T=e=>n.queue.submit([e.finish()]),L=(e,t,r=G(t))=>(e(r),T(r)),j=(e,t)=>e||i?t(e||i):L(t),R=()=>{try{o&&o.end()}catch{}o=0},I=e=>(s=e,o=u=l=0,f=!1),N=(e,t=o)=>(R(),e(),t&&(o=i[$a](u||{}),l&&l(o))),Z=(e,t,r,a,i,s=1)=>n.createTexture({size:te(e,t),[$c]:r,mipLevelCount:s,sampleCount:1,usage:a}),Q=(e,a)=>32-r(t(e,a)),H=new WeakMap,J=0,K=new WeakMap,X=(e,t=K.get(e))=>t||(K.set(e,t=e.createView()),t),Y=e=>e.createView?X(e):e,ee=(e=s||m.context&&X(m.context.getCurrentTexture()))=>(e||W(7,!1),i&&(s=e),e),te=(e,t)=>({width:e,height:t}),re=e=>(t,r=0,a)=>(E.isView(t)||t instanceof E||W(5,!1),3&r&&W(6,!1),3&D(t)&&((a=new V(D(t)+3&-4)).set(E.isView(t)?new V(t.buffer,t.byteOffset,D(t)):new V(t)),t=a),m.writeUniforms(e,t,r)),ae=e=>e&&typeof e===O?z[e]??W(4,!1):e||0,ne=(e,t,r={},a=0)=>(m.defines.replace(/([^\s,]+)[ \t]+([^,\n]+)/g,(e,t,a)=>r[t]=a.trim()),(a=S(r)).length&&(e=e.replace(new RegExp(`\\b(?:${a.sort((e,t)=>t.length-e.length).join("|")})\\b`,"g"),e=>r[e])),n.createShaderModule({code:e})),ie=(e,t)=>({module:e,entryPoint:t}),se=(e={},t={},r={})=>{let n=r.group??0,i=r.structName??"Uniforms",s=r.varName??"UB",o=r.readOnly??[],u=r.f??!0,f=r.startBinding??0,l=e=>`@group(${n}) @binding(${e}) var`,c=(e,t)=>a(e/t)*t,d=0,g=()=>0,p="",h=B(e);if(h.length>0){let e=0,t={},r=h.map(([r,a],n,i,[s,o,u,f]=((e,t,r,a)=>/^[fiu]32$/.test(e)?[1,1,1,0]:(t=/^vec([234])<[fiu]32>$/.exec(e))?[r=+t[1],3===r?4:r,r,0]:(t=/^mat([234])x([234])$/.exec(e))?[(r=+t[1])*(3===(a=+t[2])?4:a),3===a?4:a,r*a,3===a?3:0]:W(12,!1))(a))=>(e=c(e,o),t[r]={u:e,c:u,d:f,e:a},e+=s,` ${r}: ${a},`));e=c(e,4);let a=4*e;d=m.createUniformBuffer(a);let n=new E(a),o=new Float32Array(n),$=new Int32Array(n),w=new Uint32Array(n);C(t).forEach(e=>(e.i=/u32/.test(e.e),e.n=/i32/.test(e.e),e.m=e.i?w:e.n?$:o)),g=e=>{for(let[r,a]of B(e)){let e=t[r];e||W(13,!1);let n=Array.isArray(a)||E.isView(a)?a:[a];for(let t=0;t>>0:e.n?0|r:r,e.m[e.u+(e.d?4*(t/3|0)+t%3:t)]=r}}m.writeUniforms(d,n)},u&&(p=`struct ${i} {\n${r.join("\n")}\n}\n${l(f)} ${s}: ${i};`,f++)}let $={};return{wgsl:[p,...B(t).map(([e,t],r,a,n=/^read\s+/.test(t),i=/^uniform\s+/.test(t),s=t.replace(/^(read|uniform)\s+/,""),u=f++,c=s.startsWith("texture_"),m=s.startsWith("sampler"),d=!(c||m),g,p)=>(g="",p=s,d&&!s.startsWith("array<")&&(/^struct\s+/.test(s)?(g=s.trim(),p=s.match(/^struct\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1]??s):g=`struct ${p=`${e}_buf`} {\n value: ${s},\n}`),$[e]={h:u,e:s,s:d,F:d&&i,_:c,E:m},(g&&`${g}\n`)+`${l(u)}${d?i?"":``:""} ${e}: ${p};`))].filter(Boolean).join("\n"),uniformBuffer:d,uniformWrite:g,S:e=>B($).map(([t,r],a,n,i=e[t])=>i&&{binding:r.h,resource:r._?Y(i):r.s?{buffer:i}:i}).filter(Boolean),D:S(e),$:S(t),G:$,v:h.length&&u?r.startBinding??0:-1}},oe=({code:t,uniforms:r={},resources:s={},[$c]:u=m[$c],t:f=!1,blend:d,wg:g,l:p,topology:h="triangle-list",count:$=3,instances:w=1,readOnly:y=[],async:b=!1,cache:v=!0,moduleOnly:x=!1})=>{let S=t.replace(/\/\*[\s\S]*?\*\//g,"").replace(/\/\/.*/g,""),C=e=>new RegExp(`\\b${e}\\b`).test(S),A=_(B(s).filter(([e])=>C(e))),F=C("UB"),P=se(r,A,{group:0,startBinding:0,f:F,readOnly:y}),{$:U,G:O,v:M,uniformWrite:z}=P,V=P.wgsl?`${P.wgsl}\n${t}`:t;if(x)return{wgsl:V,module:m.makeShader(V)};let E,q=0,D=0,G=e=>(q=e,D&&(X(D),D=0),e),T=n&&f&&v?`C|main|${m.defines}|${V}`:0,L=T&&(e.get(n)||(e.set(n,new Map),e.get(n))),I=T&&L.get(T);I&&(L.delete(T),L.set(T,I));let N=0;if(I&&I.o)N=1,q=I.o,E=Promise.resolve(q);else if(I&&b)N=1,E=I.A.then(G);else{let e=m.makeShader(V),t=f?(e=>({layout:"auto",compute:ie(e,"main")}))(e):((e,t,r,a)=>(a=ae(a),{layout:"auto",vertex:ie(e,"vs_main"),fragment:k(ie(e,"fs_main"),{[$d]:[t].flat().map(e=>a?{[$c]:e,blend:a}:{[$c]:e})}),primitive:{topology:r}}))(e,u,h,d);if(b){let e,r=f?n.createComputePipelineAsync(t):n.createRenderPipelineAsync(t);T&&(((e,t=0)=>{L.delete(T),L.set(T,e);for(let e of L.keys())t+=e.length;for(let e of L.keys()){if(L.size<=m.pipelineCacheMax&&t<=m.pipelineCacheChars)break;t-=e.length,L.delete(e)}})(e={A:r}),r.then(t=>{e.o=t},()=>{L.get(T)===e&&L.delete(T)})),E=r.then(G,e=>{throw e})}else q=f?n.createComputePipeline(t):n.createRenderPipeline(t),E=Promise.resolve(q)}let Z=0,Q=0,K=new Map,X=(e,t,r,a)=>(t={...Q},B(e??{}).forEach(([e,r])=>(e in s||W(22,!1),e in O&&(t[e]=O[e].s&&"function"==typeof r?.b?.mapAsync?r.b:r))),Z&&Q&&U.every(e=>t[e]===Q[e])||(Q=t,r=U.map(e=>t[e]?(e=>H.get(e)??(H.set(e,++J),J))(t[e]):0).join(","),(a=K.get(r))?Z=a:(Z=m.bindGroup(q,0,[...M>=0?[{binding:0,resource:{buffer:P.uniformBuffer}}]:[],...P.S(t)]),K.size>=8&&K.delete(K.keys().next().value),K.set(r,Z)))),te=e=>{q?X(e):D={...D||{},...e||{}}};M>=0&&!U.length&&te({});let re=M>=0||U.length>0,ne=()=>(!q&&W(24,!1),!Z&&re&&W(15,!1)),oe=e=>(ne(),e.setPipeline(q),Z&&e.setBindGroup(0,Z)),ue=(e,t)=>!o||t&&t!==i?j(t,(t,r)=>(ne(),r=t[$a](c?c(fe):void 0),oe(r),e(r),r.end())):(l!==oe&&(oe(o),l=oe),e(o)),fe={get pipeline(){return q},ready:E,get wgsl(){return V},get cached(){return!!N},set uniforms(e){z(e)},setUniform:(e,t)=>z({[e]:t}),setUniforms:e=>z(e),set resources(e){te(e)},setResources:e=>te(e),count:$,instances:w},le=(e=1,t=1,r=1,a)=>ue(a=>a.dispatchWorkgroups(e,t,r),a);return k(fe,f?{dispatch:le,run:(e=1,t=1,r=1,n)=>le(...[e,t,r].map((e,t)=>a(e/g[t])),n),dispatchIndirect:(e,t=0,r)=>ue(r=>r.dispatchWorkgroupsIndirect(e.b??e,t),r),bindTo:(e=o)=>(e||W(17,!1),oe(e),e===o&&(l=oe))}:{drawTo:(e,t,r)=>{let a=Array.isArray(t)?t:[0,0,0,1],n="load"===t?"load":"clear",s={r:a[0],g:a[1],b:a[2],a:a[3]},u=p?p.map(t=>Y(e?.[t]||W(18,!1))):[e?Y(e):ee()];j(r,(e,t)=>(ne(),e===i&&o&&R(),t=e.beginRenderPass({colorAttachments:u.map(e=>({view:e,loadOp:n,storeOp:"store",clearValue:s}))}),oe(t),t.draw(fe.count,fe.instances,0,0),t.end()))}})};return k(m,{makeSchema:se,_resolveBlend:ae}),m}; /*pack:$a=beginComputePass,$b=clearBuffer,$c=format,$d=targets*/ diff --git a/docs/API.md b/docs/API.md index 791bd87..135f086 100644 --- a/docs/API.md +++ b/docs/API.md @@ -11,7 +11,8 @@ await G.init(canvas); // or a 'webgpu' context, or a CSS selector ## Setup ### `await G.init(ctx?, opts?)` -Requests adapter + device (with the adapter's max buffer-size limits) and configures the canvas. +Requests adapter + device (with the adapter's max buffer-size limits and its +`maxStorageBuffersPerShaderStage`) and configures the canvas. `ctx` can be a canvas `'webgpu'` context, a canvas element / OffscreenCanvas, or a CSS selector for one — `init('#c')` works. Omit it for compute-only use — render calls then need an explicit target view. Populates `G.device`, `G.context`, `G.format`, `G.features`. Throws if WebGPU is @@ -158,12 +159,28 @@ auto-managed texture needs the target's size, which the library knows for the ca `GPUTexture` passed to `drawTo`; handing `drawTo` a raw `GPUTextureView` it has never seen makes it throw and ask for a texture or `depth.texture`. -### `G.makeCompute(bodyWGSL, mainWGSL, uniforms?, resources?, { wg = [8,8,1] })` +### `G.makeCompute(bodyWGSL, mainWGSL, uniforms?, resources?, { wg = [8,8,1], async, cache, moduleOnly })` Compute pipeline. `bodyWGSL` = declarations (functions, structs); `mainWGSL` = statements placed inside the generated entry point, which receives `gid` (global), `lid` (local), `wid` (workgroup) invocation ids. `wg` is the `@workgroup_size`; missing axes default to 1, so `wg: [64]` means `[64, 1, 1]`. +- **`async: true`** compiles off-thread with `createComputePipelineAsync`. The handle comes back + at once with `p.pipeline` still `0` and a `p.ready` promise; await it before dispatching (a + dispatch before then throws and says so). `setResources` called meanwhile is kept and bound + when the pipeline arrives. Worth it for big shaders, whose synchronous compile blocks the page. +- **The compute pipeline cache.** The same WGSL text (schema, `@workgroup_size` and `G.defines` + included) on the same device compiles once: a second `makeCompute` gets the first one's + `GPUComputePipeline` (immutable, so shared safely) and its own uniform buffer and bind groups. + `p.cached` says whether it was a hit. Bounded by `G.pipelineCacheMax` (48 entries) and + `G.pipelineCacheChars` (24e6 characters of key); the least recently used goes first. A + synchronous pipeline is entered only once its error scopes come back clean, which DIAG builds + alone check, so the minified builds cache async pipelines only. `cache: false` neither looks + up nor enters — for measuring what a compile costs. +- **`moduleOnly: true`** returns `{ wgsl, module }` — the exact source and its shader module — + and makes no pipeline. `module.getCompilationInfo()` then validates the WGSL without handing it + to the driver's compiler. + ### Schemas - `uniforms`: `{ name: 'f32' | 'i32' | 'u32' | 'vec2/3/4' | 'matCxR' }` → generated into `struct Uniforms {...}` bound as `UB` at `@group(0) @binding(0)`. Layout @@ -179,6 +196,10 @@ inside the generated entry point, which receives `gid` (global), `lid` (local), buffer. Supported forms: - `'array'` → `var` runtime array (T may be a struct you define in `bodyWGSL` or inline via the struct form below) + - `'read array'` (any storage type after `read `) → bound `var` by every + pipeline that declares it, as if named in that pipeline's `readOnly` + - `'uniform array, 256>'` (any sized type after `uniform `) → bound `var`; + its buffer needs UNIFORM usage and fits `maxUniformBufferBindingSize` (64 KiB by default) - `'struct Foo { ... }'` → struct emitted + bound as `var name: Foo` - primitive/vector/matrix → auto-wrapped in a single-field struct, bound as storage - `'texture_2d'`, `'texture_storage_2d'`, … → texture binding @@ -189,7 +210,10 @@ inside the generated entry point, which receives `gid` (global), `lid` (local), |---|---| | `p.uniforms = {vals}` / `p.setUniforms(vals)` / `p.setUniform(name, v)` | Merge values into the CPU staging struct and upload the whole buffer. Vectors/matrices take arrays or TypedArrays (or scalars for 1-wide). A name that is not in the schema throws. | | `p.resources = {vals}` / `p.setResources(vals)` | (Re)build the bind group. Pass buffer handles (`{b,w,r}`) or raw `GPUBuffer`s, and textures or views for textures. **Merged** into what is already bound, so partial updates are fine, and **compared by value**, so re-passing the same resources every frame is free. Bind groups are cached per resource combination (up to 8), so a ping-pong that alternates two states builds two groups total, not one per swap. A name that is not in the schema throws, like `setUniforms`. | -| `p.pipeline` | The raw `GPURenderPipeline`/`GPUComputePipeline`. | +| `p.pipeline` | The raw `GPURenderPipeline`/`GPUComputePipeline` — `0` until `p.ready` settles for an `async: true` one. | +| `p.ready` | Promise of the pipeline. Already resolved for a synchronous pipeline, so `await p.ready` is always safe. | +| `p.wgsl` | The source the driver was given, schema and `G.defines` included. | +| `p.cached` | Whether a compute pipeline came from the device's pipeline cache. | | `p.uniformFields` / `p.resourceFields` | Names, in binding order. | Compute-only: @@ -228,6 +252,12 @@ Render-only: render as large as `maxTextureDimension2D` allows and save once. The canvas texture is not saveable unless you passed `canvasUsage` with `COPY_SRC` to `init`. +### `G.timePasses(fn)` +`fn(pipeline)` may return a `GPUComputePassDescriptor` — typically `{ timestampWrites }` — for +each compute pass a dispatch opens for itself; returning nothing opens a plain pass. A +`beginCompute()` chain's single pass keeps the descriptor it was opened with. `null` turns it off. +Needs the `'timestamp-query'` feature for timestamps. + ## Frame API (batching) ```js diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1ab7b4b..9050980 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,7 +2,38 @@ All notable changes to TinyWebGPU. Semver; pre-1.0, minor versions may break API. -## Unreleased +## 0.5.0 — 2026-10-10 + +**Added — what ShadyTracer's copy had grown, brought home.** ShadyTracer (lampmaker/ShadyTracer) +carried its own copy of this file and had moved ahead of the release; it now consumes this +repository instead, so its additions land here: +- **`async: true`** on `makeCompute`/`makeCompute2D` (and the pipeline factory): compiled with the + `create*PipelineAsync` twins, `p.pipeline` a getter that is `0` until `p.ready` settles, and + resources bound meanwhile parked and replayed. A dispatch before then throws error 24 by name. +- **A compute pipeline cache per device**: the same text compiles once (`p.cached`), bounded by + `G.pipelineCacheMax` and `G.pipelineCacheChars`, least recently used first; `cache: false` opts + out. Keyed by the whole text, never a hash. Synchronous pipelines are cached only once their + error scopes come back clean, so only DIAG builds cache them. +- **`moduleOnly: true`** returns `{ wgsl, module }` without a pipeline; **`p.wgsl`** is the source + the driver was given. +- **`read ` and `uniform ` resource prefixes**: a buffer bound read-only wherever it is declared, + or bound `var` with UNIFORM usage. +- **`G.timePasses(fn)`**: a descriptor (e.g. `timestampWrites`) for each compute pass a dispatch + opens. +- `init()` also requests the adapter's `maxStorageBuffersPerShaderStage`. +- `makeCompute` and `makeCompute2D` pass every option through, where they used to keep only `wg`. + +**Changed** — DIAG builds push out-of-memory and internal error scopes as well as validation around +pipeline creation. The checks a dispatch or draw makes now run before a pass is opened, so a throw +no longer leaves the frame's encoder with an open pass. + +**Fixed** — a dispatch of an async pipeline still compiling, with its resources already set, said +"No resources bound"; it now says the pipeline is still compiling. + +**Build** — `build-min` keeps the `export let WEBGPU=()=>{` splice working when esbuild folds the +module-level cache declaration into the factory's statement. + +## Unreleased (folded into 0.5.0) **Fixed — the demo pages load their library again when the site is not at the root of its origin** diff --git a/package.json b/package.json index fdf63da..45761db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tinywebgpu", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "description": "One file, no dependencies, no build step. A WebGPU micro-framework for shader-first work: dual-schema pipelines, fullscreen shaders and compute.", "main": "./src/tinywebgpu.js", diff --git a/src/tinywebgpu.d.ts b/src/tinywebgpu.d.ts index 9c1d371..c4f17d8 100644 --- a/src/tinywebgpu.d.ts +++ b/src/tinywebgpu.d.ts @@ -58,7 +58,14 @@ export interface PingPong { } export interface Pipeline { - pipeline: GPURenderPipeline | GPUComputePipeline; + /** 0 until `ready` settles for an `async: true` pipeline; the pipeline itself otherwise. */ + readonly pipeline: GPURenderPipeline | GPUComputePipeline | 0; + /** Resolves to the pipeline once compiled. Already resolved for a synchronous one. */ + readonly ready: Promise; + /** The source the driver was given: schema, entry point and G.defines included. */ + readonly wgsl: string; + /** Whether the pipeline came from the device's compute pipeline cache. */ + readonly cached: boolean; /** Merge values into the CPU struct and upload. Unknown names throw. */ uniforms: Record; setUniforms(values: Record): void; @@ -125,6 +132,15 @@ export interface MakeDrawOptions { depth?: Depth | None; } +export interface ComputeOptions { + /** @workgroup_size, default [8,8,1]. */ + wg?: number[]; + /** Compile off-thread: `pipeline` is 0 until `ready` settles. */ + async?: boolean; + /** false: always compile, and keep nothing in the device's pipeline cache. Default true. */ + cache?: boolean; +} + export interface MakeFragOptions { format?: GPUTextureFormat | GPUTextureFormat[]; blend?: Blend | None; @@ -158,12 +174,15 @@ export interface TinyWebGPU { makeDraw(opts: MakeDrawOptions): RenderPipeline; /** Compute pipeline: `body` = declarations, `main` = entry-point statements (gid/lid/wid in scope). */ makeCompute(body: string, main: string, uniforms?: UniformSchema, resources?: ResourceSchema, - opts?: { wg?: number[] }): ComputePipeline; + opts?: ComputeOptions & { moduleOnly?: false }): ComputePipeline; + /** `moduleOnly: true`: the WGSL and its shader module as the pipeline would have them, and no pipeline. */ + makeCompute(body: string, main: string, uniforms: UniformSchema | None, resources: ResourceSchema | None, + opts: ComputeOptions & { moduleOnly: true }): { wgsl: string; module: GPUShaderModule }; /** makeFrag plus `run(uniforms?, view?)`. All makeFrag options pass through. */ makeQuad(opts: { frag: string; uniforms?: UniformSchema; resources?: ResourceSchema; clear?: Clear } & MakeFragOptions): QuadPipeline; /** makeCompute with a default grid size, so `run()` with no arguments covers it. */ makeCompute2D(opts: { body: string; decls?: string; uniforms?: UniformSchema; resources?: ResourceSchema; - size?: number[]; wg?: number[] }): ComputePipeline; + size?: number[] } & ComputeOptions): ComputePipeline; // ─── Frame API ──────────────────────────────────────────────────────────────────────────── /** One encoder + one swapchain view for the whole frame; endFrame() is the single submit. */ @@ -171,6 +190,12 @@ export interface TinyWebGPU { endFrame(): void; /** beginFrame/endFrame around `fn`, exception-safe. Returns fn's result. */ frame(fn: () => T, opts?: { view?: GPUTextureView }): T; + /** Per-pass descriptors (e.g. timestampWrites) for each compute pass a dispatch opens itself; null stops. */ + timePasses(fn: ((pipeline: ComputePipeline) => GPUComputePassDescriptor | undefined) | None): void; + /** Bounds of the per-device compute pipeline cache: entries, and total characters of their WGSL. */ + pipelineCacheMax: number; + pipelineCacheChars: number; + /** Open a chained compute pass: dispatches join it and rebind only on pipeline change. */ beginCompute(opts?: GPUComputePassDescriptor): GPUComputePassEncoder; endCompute(): void; diff --git a/src/tinywebgpu.js b/src/tinywebgpu.js index 07452b8..41f1fc1 100644 --- a/src/tinywebgpu.js +++ b/src/tinywebgpu.js @@ -16,6 +16,10 @@ Terminology cheatsheet: * @typedef {Object.} ResourceSchema * name → full WGSL resource type: 'array', 'struct Foo {...}', 'texture_2d', * 'texture_storage_2d<...>', 'sampler', or a primitive (auto-wrapped in a struct). + * A buffer's type may start with `read ` ('read array'): bound `var` by every + * pipeline that declares it, as if named in that pipeline's `readOnly`. Or with `uniform ` + * ('uniform array, 4096>'): bound `var`, a buffer made with UNIFORM usage -- + * a sized type, at most the device's maxUniformBufferBindingSize (64 KiB by default). * * @typedef {Object} BufferHandle * @property {GPUBuffer} b - the raw buffer; alias `buffer` @@ -104,6 +108,13 @@ const F_ALIASES = true; // The generated uniform variable's name; makePipeline tests the shader for it. const UNIFORM_VAR = 'UB'; +// THE COMPUTE PIPELINE CACHE: GPUDevice → Map(key → entry), shared by every instance on that device +// (makePipeline explains the key and the entries). Keyed by the device rather than the instance, +// so a second WEBGPU() on the same device shares it, and a new device — `S.device = d`, or init() +// again — starts empty instead of handing out pipelines another device made. A WeakMap, so a +// device let go takes its pipelines with it. +let pipeCaches = new WeakMap(); + @@ -146,7 +157,8 @@ export let WEBGPU = () => { fPass = 0, // the open chained compute pass, if beginCompute() opened one fPassOpts = 0, // that pass's descriptor, so a mid-chain reopen keeps e.g. timestampWrites fOwned = false, // the encoder came from beginCompute(), so endCompute() submits it - fBound = 0; // the bind fn of the pipeline currently bound to fPass + fBound = 0, // the bind fn of the pipeline currently bound to fPass + passTimer = 0; // timePasses(): a descriptor for each compute pass a dispatch opens // ══════════════════════════════════════════════════════════════════════════════════════════════ // PUBLIC API @@ -169,6 +181,12 @@ export let WEBGPU = () => { // Called with the GPUDeviceLostInfo on a context crash, driver reset or tab backgrounding. // The library logs regardless; set this to rebuild. onDeviceLost: 0, + // The compute pipeline cache's bounds (see makePipeline): how many pipelines it keeps, and the + // total characters of their keys. The second is there because a key is the whole shader text, + // and a path tracer's kernels run to hundreds of thousands of characters each; the first + // because what a cached pipeline holds on the GPU side is not known. The oldest-used goes + // first. The cache is the device's, so the instance that inserts applies its own bounds. + pipelineCacheMax: 48, pipelineCacheChars: 24e6, @@ -208,8 +226,14 @@ export let WEBGPU = () => { ctx?.getContext && (ctx = ctx.getContext('webgpu')), // a canvas rather than a context (a = await gpu.requestAdapter()) || err(2, MSG && 'No GPU adapter'), // Raise these to whatever the adapter allows; anything in opts.limits still wins. + // Asking for exactly what the adapter reports can never fail, which is what makes this + // safe to do unconditionally. maxStorageBuffersPerShaderStage defaults to 8 and is the + // one that bites without warning: a kernel grows a ninth binding and pipeline creation + // throws at setScene, pages away from the line that added it. Desktop adapters report + // 16 or more; one that reports 8 still gets 8 and the kernel still has to fit. d = { ...opts.limits }, - ['maxStorageBufferBindingSize', 'maxBufferSize'].forEach(k => d[k] ??= a.limits[k]), + ['maxStorageBufferBindingSize', 'maxBufferSize', + 'maxStorageBuffersPerShaderStage'].forEach(k => d[k] ??= a.limits[k]), // Asking for a feature the adapter lacks would throw, so filter first. w = opts.features ?? [], DIAG && (drop => drop.length && console.warn( @@ -241,6 +265,7 @@ export let WEBGPU = () => { F_DEPTH && (depthCache.forEach(t => t.destroy()), depthCache.clear()), F_SHOW && blitCache.clear(), F_MIPS && mipCache.clear(), + D && pipeCaches.delete(D), D && D.destroy(), // `&&`, not `?.` — the empty value is 0, which `?.` would try to call through D = 0), @@ -324,13 +349,21 @@ ${frag} * statements of the generated entry point, which receives `gid`, `lid` and `wid`. * @param {string} body @param {string} main * @param {UniformSchema} [uniforms] @param {ResourceSchema} [resources] - * @param {{wg?: number[]}} [opts] workgroup size, default [8,8,1] + * @param {{wg?: number[], async?: boolean, cache?: boolean, moduleOnly?: boolean}} [opts] workgroup size, default [8,8,1]; + * `async: true` compiles off-thread — see the `async` option on the pipeline factory; + * `cache: false` always compiles and keeps nothing, for measuring what a compile costs — + * by default the same text on the same device is compiled once (see the pipeline factory) + * and the handle's `cached` says whether it was; + * `moduleOnly: true` returns `{ wgsl, module }` and makes no pipeline, nor any cache entry — see the factory * @returns {ComputePipeline} */ - makeCompute: (body, main, uniforms, resources, { wg = [8, 8, 1] } = {}) => ( + makeCompute: (body, main, uniforms, resources, { wg = [8, 8, 1], ...opts } = {}) => ( // Missing axes default to 1 — `wg: [64]` used to interpolate `undefined` into the WGSL. wg = [wg[0] ?? 1, wg[1] ?? 1, wg[2] ?? 1], makePipeline({ + // ...and everything else (today: `async`, `cache`, `moduleOnly`) spreads through to makePipeline, where its + // default lives. Naming only `wg` here used to drop the rest in silence. + ...opts, // Kept flush-left: this text is prepended to every generated shader, so it is hashed, // compiled, and line-numbered in compile errors. `${wg}` renders as `x,y,z`. code: `${body} @@ -358,8 +391,8 @@ ${main} * @returns {ComputePipeline & {run: (w?: number, h?: number, d?: number) => void}} */ // `p` and `run` are trailing-parameter locals; `run` captures p.run before OA overwrites it. - makeCompute2D: ({ body, decls = '', uniforms, resources, size = [1, 1], wg }, p, run) => ( - p = S.makeCompute(decls, body, uniforms, resources, { wg }), + makeCompute2D: ({ body, decls = '', uniforms, resources, size = [1, 1], wg, ...opts }, p, run) => ( + p = S.makeCompute(decls, body, uniforms, resources, { wg, ...opts }), run = p.run, OA(p, { run: (w = size[0], h = size[1], d = 1, encoder) => run(w, h, d, encoder) })), @@ -415,6 +448,11 @@ ${main} endCompute: () => ( endPass(), fOwned && S.endFrame()), // beginCompute() opened the encoder; nothing else submits it + // PASS TIMING: fn(program) may return a descriptor for each compute pass a dispatch opens for + // itself, or nothing for a plain one. The path tracer's per-kernel GPU time puts timestampWrites + // there (pathtracer/core/kerneltime.js). Not for a beginCompute() chain: its one pass keeps the + // descriptor it was opened with. null stops it. + timePasses: fn => { passTimer = fn || 0; }, @@ -1114,16 +1152,19 @@ ${main} // order; a blend state, if given, applies to every target. // The {module, entryPoint} stage descriptor, spelled once for all three stages. let stage = (module, entryPoint) => ({ module, entryPoint }); - let rawRender = (module, format, topology, blend, dep) => ( + // The descriptors are built apart from the create call so the same one serves + // createRenderPipeline / createComputePipeline and their Async twins — see the `async` + // option on makePipeline. + let renderDesc = (module, format, topology, blend, dep) => ( blend = resolveBlend(blend), - D.createRenderPipeline({ + { layout: 'auto', vertex: stage(module, 'vs_main'), fragment: OA(stage(module, 'fs_main'), { targets: [format].flat().map(f => blend ? { format: f, blend } : { format: f }) }), primitive: { topology }, ...(F_DEPTH && dep ? { depthStencil: { format: dep.format, depthWriteEnabled: dep.write, depthCompare: dep.compare } } : {}), - })); - let rawCompute = module => D.createComputePipeline({ layout: 'auto', compute: stage(module, 'main') }); + }); + let computeDesc = module => ({ layout: 'auto', compute: stage(module, 'main') }); // ─── Schema engine ───────────────────────────────────────────────────────────────────────── // Turns the two schema objects into WGSL declarations, a CPU-side uniform struct with a writer, @@ -1220,8 +1261,19 @@ ${structFields.join('\n')} // Anything that is not a texture or sampler is a storage buffer and needs an address space. // Three forms: `array<…>` is kept as-is, a full `struct Foo {…}` is emitted and bound by name, // and a bare primitive/vector/matrix is auto-wrapped in `struct name_buf { value: T }`. + // A BUFFER THAT SAYS `read ` IS BOUND READ-ONLY WHEREVER IT IS DECLARED. `readOnly` is per + // pipeline, so a buffer shared by many pipelines (a scene's, handed to every kernel that reads + // the scene) was read_write wherever one call site forgot it -- all of them, for the scene as + // data. The marker travels with the resource instead, and is stripped here: `_wgslType` and the + // WGSL are the type alone. + // A BUFFER THAT SAYS `uniform ` IS BOUND `var`: small, read-only, and read through the + // path GPUs keep for values every thread reads alike (a scene's records, walked in step by every + // ray: builder/scenedata.js). Its type must be sized, and its buffer made with UNIFORM usage. let resourceLayout = {}; - let resourceWGSL = OE(resources).map(([name, wgslType], i, a, + let resourceWGSL = OE(resources).map(([name, declared], i, a, + ro = /^read\s+/.test(declared), + uni = /^uniform\s+/.test(declared), + wgslType = declared.replace(/^(read|uniform)\s+/, ''), b = binding++, isTex = wgslType.startsWith('texture_'), isSampler = wgslType.startsWith('sampler'), // 'sampler' | 'sampler_comparison' @@ -1234,9 +1286,9 @@ ${structFields.join('\n')} typeForBinding = wgslType.match(/^struct\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? wgslType) : (typeForBinding = `${name}_buf`, decls = `struct ${typeForBinding} {\n value: ${wgslType},\n}`)), - resourceLayout[name] = { _binding: b, _wgslType: wgslType, _isBuf: isBuf, _isTex: isTex, _isSampler: isSampler }, + resourceLayout[name] = { _binding: b, _wgslType: wgslType, _isBuf: isBuf, _isUniform: isBuf && uni, _isTex: isTex, _isSampler: isSampler }, (decls && `${decls}\n`) + - `${gb(b)}${isBuf ? `` : ''} ${name}: ${typeForBinding};`)); + `${gb(b)}${!isBuf ? '' : uni ? '' : ``} ${name}: ${typeForBinding};`)); // `wgsl` / `uniformBuffer` / `uniformWrite` are the documented shape and keep their names. The // rest is internal, so it is `_`-prefixed and the minified build renames it. @@ -1260,13 +1312,20 @@ ${structFields.join('\n')} // ─── Pipeline factory ────────────────────────────────────────────────────────────────────── // The engine behind makeFrag / makeDraw / makeCompute: generate the schema WGSL, compile the - // pipeline, and hand back the object those three return. Synchronous throughout — every + // pipeline, and hand back the object those three return. Synchronous by default — every // WebGPU call on this path is synchronous by spec (see compileShader above), so the pipeline // is usable on the next line and pieces never await a factory. + // `async: true` opts out of exactly that, and only that. createRenderPipeline / + // createComputePipeline hand the module to the driver and BLOCK the calling thread until it + // has compiled it; for a big shader that is seconds to a minute in which the page cannot + // paint. The Async twins do the same work off-thread, so the handle comes back with its + // `pipeline` still empty and a `ready` promise that settles when it is filled. Await `ready` + // before dispatching or drawing — bind() says so by name if you forget. // `wg` has no default: makeCompute always normalizes and passes it, and render never reads it. let makePipeline = ({ code, uniforms = {}, resources = {}, format = S.format, _isCompute: isCompute = false, blend, wg, _targetNames: targetNames, - topology = 'triangle-list', count = 3, instances = 1, readOnly = [], depth }) => { + topology = 'triangle-list', count = 3, instances = 1, readOnly = [], depth, + async: isAsync = false, cache: useCache = true, moduleOnly = false }) => { // The resolved depth config: defaults, overridable field by field. `texture` is the caller's // own depth attachment; without it drawTo manages one sized to the target. let dep = F_DEPTH && depth && !isCompute @@ -1298,14 +1357,107 @@ ${structFields.join('\n')} // Prepend the schema WGSL; compileShader expands G.defines on the complete source. let finalCode = schema.wgsl ? `${schema.wgsl}\n${code}` : code; - // The error scope is purely for reporting: WebGPU rejects a bad pipeline either way, and - // without DIAG the driver's own message is the one that surfaces. - if (DIAG) D.pushErrorScope('validation'); - let module = S.makeShader(finalCode); - let pipeline = isCompute ? rawCompute(module) : rawRender(module, format, topology, blend, dep); - if (DIAG) D.popErrorScope().then(err => { - err && console.error(`[TinyWebGPU] Pipeline creation failed:\n${err.message}`); - }).catch(() => { }); // scope pop rejects if the device is lost meanwhile + // `moduleOnly: true`: the source and its module exactly as the pipeline would have them -- schema, entry + // point, G.defines -- and no pipeline. For a caller that only asks whether the WGSL is valid: the module's + // getCompilationInfo() answers that from the browser's front end (parse, types, uniformity), while making + // a pipeline also hands it to the driver's compiler. Measured by the user: 27-33 ms for a march kernel's + // module (Pascal, 2026-10-02); the builder's probe, made as a pipeline, recorded at 1.2-3.1 s (PERFORMANCE.md, + // a structural edit end to end). What only the driver refuses, this cannot see. + if (moduleOnly) return { wgsl: finalCode, module: S.makeShader(finalCode) }; + + // Sync: `pipeline` is filled below, before makePipeline returns. Async: it stays 0 until `ready` + // settles, and an async creation failure arrives as a rejection rather than through the error + // scope, so it is reported on the promise instead. + let pipeline = 0; + // layout:'auto' means the bind group layout comes FROM the pipeline, so a bind group cannot + // be built before it exists. setResources on a pipeline still compiling therefore parks its + // values here and they are replayed the moment it arrives — callers bind resources when they + // have them, not when the driver is finished. + let pending = 0; + let ready; + // What an async pipeline does when it arrives, whether it was compiled here or by an earlier + // request for the same text: fill the handle, then replay what setResources parked. + let arrive = p => (pipeline = p, pending && (rebindResources(pending), pending = 0), p); + + // THE COMPUTE PIPELINE CACHE. A revisit — a scene switched back to, a structural edit undone, a + // second renderer (the raytracer's studio) building what the first already has, a failed + // scene change rebuilding the scene before — asks for text this device has compiled already, + // and without a cache every one of them was a module and a pipeline again, and a compile + // again whenever the browser's own cache did not answer. With it the same text on the same + // device makes no shader module and no pipeline: only the handle's own uniform buffer (made + // above) and its bind groups, which every handle needs whoever compiled its pipeline. + // What is shared is only the GPUComputePipeline, which is immutable. Each handle keeps its own + // uniform buffer (buildSchema made it above) and builds its own bind groups from that pipeline's + // layout, so two handles on one pipeline never see each other's values. + // THE KEY IS THE WHOLE TEXT, not a hash of it: two texts that hashed alike would hand one the + // other's pipeline — the collision the old toolkit's hashed cache had (toolkit BUG-008) — and + // a Map lookup on a 348K-character key is about 0.2 ms (measured in node). finalCode holds the + // schema and the @workgroup_size; G.defines is expanded later, at compile, so it joins the key; + // the entry point and layout:'auto' are fixed by computeDesc. Pipeline `constants`, if ever + // added to computeDesc, must join the key too. Compute only: a render pipeline also depends on + // its format, blend, topology and depth, and none here is costly enough to be worth keying. + // An entry is { _p } once its pipeline exists, { _r } while an async one is compiling: a second + // async request for that text waits on the same compile rather than starting its own, and a + // rejection drops the entry so the next request compiles again. A sync pipeline is entered only + // once its error scopes have come back clean (DIAG builds): sync creation never throws, so before + // that nothing says it is not an invalid pipeline — and without DIAG nothing ever says, so sync + // pipelines are not cached at all there. Clean means of every kind, not validation alone: a + // scope catches only its own kind, and a sync pipeline that failed with an internal error (a + // backend compile that failed) would otherwise pass as clean and be handed, invalid, to every + // later request for its text instead of the compile that might succeed. Async needs none of + // this: createComputePipelineAsync rejects on either kind. A sync request that finds an async compile still in + // flight compiles its own: it cannot wait. `cache: false` neither looks up nor enters, for the + // measurements that exist to compile (the path tracer's profile.js profileWG): its throwaway + // pipelines would otherwise also push the real ones out. + let key = D && isCompute && useCache ? `C|main|${S.defines}|${finalCode}` : 0; + let pc = key && (pipeCaches.get(D) || (pipeCaches.set(D, new Map()), pipeCaches.get(D))); + let hit = key && pc.get(key); + hit && (pc.delete(key), pc.set(key, hit)); // the most recently used is last + // Enter `e` under the key, then drop the least recently used until both bounds hold — an entry + // larger than pipelineCacheChars by itself is dropped too, so it is simply not kept. + let put = (e, n = 0) => { + pc.delete(key); pc.set(key, e); + for (let k of pc.keys()) n += k.length; + for (let k of pc.keys()) { + if (pc.size <= S.pipelineCacheMax && n <= S.pipelineCacheChars) break; + n -= k.length; pc.delete(k); + } + }; + // Whether this handle's pipeline came from the cache, compiled or in flight (the `cached` getter). + let cached = 0; + if (hit && hit._p) { + cached = 1; + pipeline = hit._p; + ready = Promise.resolve(pipeline); + } else if (hit && isAsync) { + cached = 1; + // The first request logs a failure; this one only passes the rejection on. + ready = hit._r.then(arrive); + } else { + // The error scopes are for reporting, and for the cache's sync entry above: WebGPU rejects a + // bad pipeline either way, and without DIAG the driver's own message is the one that surfaces. + // One per kind of error, popped together below. + if (DIAG) D.pushErrorScope('out-of-memory'), D.pushErrorScope('internal'), D.pushErrorScope('validation'); + let module = S.makeShader(finalCode); + let desc = isCompute ? computeDesc(module) : renderDesc(module, format, topology, blend, dep); + if (isAsync) { + let raw = isCompute ? D.createComputePipelineAsync(desc) : D.createRenderPipelineAsync(desc), ent; + // The entry's own handlers, on a branch of their own: `ready` below still rejects for the caller. + key && (put(ent = { _r: raw }), raw.then(p => { ent._p = p; }, () => { pc.get(key) === ent && pc.delete(key); })); + ready = raw.then(arrive, e => { + console.error(`[TinyWebGPU] Pipeline creation failed:\n${e.message || e}`); + throw e; + }); + } else { + pipeline = isCompute ? D.createComputePipeline(desc) : D.createRenderPipeline(desc); + ready = Promise.resolve(pipeline); + } + if (DIAG) Promise.all([D.popErrorScope(), D.popErrorScope(), D.popErrorScope()]).then(errs => { + let err = errs.find(e => e); + err && console.error(`[TinyWebGPU] Pipeline creation failed:\n${err.message}`); + !err && key && !isAsync && put({ _p: pipeline }); // free of every kind of error only now + }).catch(() => { }); // scope pop rejects if the device is lost meanwhile + } let bindGroup = 0, bound = 0; // `bound` = the resource map the current group was built from @@ -1314,7 +1466,8 @@ ${structFields.join('\n')} // of driver validation text into one line naming the resource and the missing flag. Diagnostics // only — WebGPU validates the bind group itself, so the minified build folds this away. let usageNeed = !DIAG ? 0 : info => - info._isBuf ? [BUF_STORAGE, 'GPUBufferUsage.STORAGE'] + info._isUniform ? [BUF_UNIFORM, 'GPUBufferUsage.UNIFORM'] + : info._isBuf ? [BUF_STORAGE, 'GPUBufferUsage.STORAGE'] : info._isTex ? (/^texture_storage_/.test(info._wgslType) ? [TEX_STORAGE_BINDING, 'GPUTextureUsage.STORAGE_BINDING (use createStorageTexture)'] : [TEX_BINDING, 'GPUTextureUsage.TEXTURE_BINDING']) @@ -1382,37 +1535,67 @@ ${structFields.join('\n')} bgCache.size >= 8 && bgCache.delete(bgCache.keys().next().value), // oldest out bgCache.set(key, bindGroup)))); + // The public door onto rebindResources: binds now if the pipeline is here, parks the values + // if it is still compiling (see `pending` above). Parking MERGES, so a caller that binds its + // resources over several calls gets the same result either side of `ready`. + let setRes = values => { + if (pipeline) { rebindResources(values); return; } + pending = { ...(pending || {}), ...(values || {}) }; + }; + // Nothing left for the caller to supply: build @group(0) now. Otherwise wait for // setResources, so the group is never built against a layout still missing entries. - if (uBinding >= 0 && !rFields.length) rebindResources({}); + if (uBinding >= 0 && !rFields.length) setRes({}); // Whether @group(0) exists at all. If it does and nothing is bound to it, dispatching produces // a bare "bind group 0 is not set" from the driver — say which call is missing instead. let needsBindGroup = uBinding >= 0 || rFields.length > 0; - let bind = pass => ( + // The two ways a dispatch or draw cannot go ahead, as a check of its own so it runs BEFORE a pass + // is opened. It ran inside bind(), after beginComputePass/beginRenderPass, so the throw left that + // pass open: nothing here knew of it, and the frame's finish() then failed on it ("Command buffer + // recording ended before [ComputePassEncoder] was ended") -- one error became three, and the + // frame was lost. A renderer that dispatched a pipeline still compiling did exactly that. + let check = () => ( + // An `async: true` pipeline is empty until the driver has compiled it. Saying so beats + // the driver's "setPipeline: parameter is not of type GPUComputePipeline". Asked first: its + // bind group cannot exist before it does (setResources parks the values until then), so + // asked second it was always "No resources bound", to a caller that had bound them. + !pipeline && err(24, MSG && `Pipeline is still compiling. This pipeline was made with { async: true }; await its \`ready\` promise before dispatching/drawing.`), !bindGroup && needsBindGroup && - err(15, MSG && `No resources bound. Call setResources({ ${rFields.join(', ')} }) before dispatching/drawing.`), + err(15, MSG && `No resources bound. Call setResources({ ${rFields.join(', ')} }) before dispatching/drawing.`)); + let bind = pass => ( + check(), pass.setPipeline(pipeline), bindGroup && pass.setBindGroup(0, bindGroup)); // Records compute work. When beginCompute() has a pass open on the encoder we would use, join // it — rebinding only if the last pipeline bound to it was a different one — so chaining is // what you get by default and `dispatch()` never silently ends someone else's chain. - // Otherwise open a pass of our own, and submit if we also own the encoder. + // Otherwise open a pass of our own, and submit if we also own the encoder -- checked first (above). let computePass = (record, encoder) => fPass && (!encoder || encoder === fEnc) ? (fBound !== bind && (bind(fPass), fBound = bind), record(fPass)) - : onEncoder(encoder, (enc, pass = enc.beginComputePass()) => - (bind(pass), record(pass), pass.end())); + : onEncoder(encoder, (enc, pass) => + (check(), pass = enc.beginComputePass(passTimer ? passTimer(base) : undefined), bind(pass), record(pass), pass.end())); let base = { - pipeline, + // A getter, not a value: with { async: true } the pipeline arrives later. + get pipeline() { return pipeline; }, + // Resolves to the GPUPipeline once the driver has compiled it. Already resolved for a + // synchronous pipeline, so `await p.ready` is safe and free whichever way it was made. + ready, + // The source the driver was actually given, schema and G.defines included. Worth having + // when a shader takes a long time to compile: its size is the thing that predicts that. + get wgsl() { return finalCode; }, + // Whether the pipeline came from the device's compute pipeline cache rather than a compile of + // its own (see `cache` above) — true too while it waits on a compile another request started. + get cached() { return !!cached; }, set uniforms(values) { uWrite(values); }, setUniform: (name, value) => uWrite({ [name]: value }), setUniforms: values => uWrite(values), - set resources(values) { rebindResources(values); }, - setResources: values => rebindResources(values), + set resources(values) { setRes(values); }, + setResources: values => setRes(values), ...(F_ALIASES ? { get uniformFields() { return schema._uniformFields; }, get resourceFields() { return rFields; }, @@ -1457,6 +1640,7 @@ ${structFields.join('\n')} : [view ? asView(view) : targetView()]; onEncoder(encoder, (enc, pass) => ( + check(), // before the pass opens (see check) enc === fEnc && fPass && endPass(), // a render pass cannot nest inside the compute pass pass = enc.beginRenderPass({ colorAttachments: views.map(v => ({ view: v, loadOp, storeOp: 'store', clearValue })), diff --git a/test/layout.test.mjs b/test/layout.test.mjs index f78644a..cba7cd2 100644 --- a/test/layout.test.mjs +++ b/test/layout.test.mjs @@ -6,6 +6,9 @@ const ENTRY = process.env.TWG_ENTRY ?? '../src/tinywebgpu.js'; const { WEBGPU } = await import(ENTRY); +// The source build. A minified one has DIAG off (so a sync pipeline is never cached: nothing says +// it compiled clean) and mangles internal names, so what rests on either is asked of the source only. +const SOURCE = !process.env.TWG_ENTRY; // The long-form and one-letter tables the defines tests exercise (the tutorial ships a // similar one — a table is just a string, so there is no module to import). const TOKENS = ` @@ -82,6 +85,21 @@ const layoutOf = uniforms => { check('vec3+u32 written uints', [...new Uint32Array(lastWrite)], [10, 20, 30, 7]); } +// --- a write MERGES into the struct: a field not named keeps its value ------ +// (API.md, setUniforms). A caller that hands the whole bag once and then only what moves rests on +// it: a write that cleared what it did not name would zero every other field from the second on. +{ + const { write } = layoutOf({ a: 'f32', b: 'f32', v: 'vec2' }); + write({ a: 1, b: 2, v: [5, 6] }); + write({ a: 3 }); + check('a later write names one field, the others keep theirs', [...new Float32Array(lastWrite)], [3, 2, 5, 6]); + write({ v: [7, 8] }); + check('...and the whole struct is what is written each time', [...new Float32Array(lastWrite)], [3, 2, 7, 8]); + let threw = false; + try { write({ notDeclared: 1 }); } catch { threw = true; } + check('a name the schema does not declare throws (so a caller with a wider bag must filter it)', threw, true); +} + // --- createStorageBuffer: size vs create-and-fill ------------------- { const bySize = S.createStorageBuffer(64); @@ -119,6 +137,30 @@ const layoutOf = uniforms => { check('uniform write rejects an unknown name', /Unknown uniform 'tiem'/.test(threw), true); } +// --- A BUFFER MARKED `read ` IS BOUND READ-ONLY WHEREVER IT IS DECLARED ------- +// (`readOnly` is per pipeline, so a buffer many pipelines share was read_write wherever one call +// site forgot it; the marker travels with the resource instead) +{ + const r = S.makeSchema({}, { a: 'read array>', b: 'array', c: 'array' }, { readOnly: ['c'] }); + const decl = name => (r.wgsl.match(new RegExp(`var(<[^>]*>)? ${name}: [^;]*;`)) || [''])[0]; + check('`read array` binds var, the marker gone from the type', decl('a'), 'var a: array>;'); + check('an unmarked buffer stays read_write', decl('b'), 'var b: array;'); + check('readOnly still works for one pipeline', decl('c'), 'var c: array;'); + check('the layout keeps the type alone', r._resourceLayout?.a?._wgslType ?? 'array>', 'array>'); +} + +// --- A BUFFER MARKED `uniform ` IS BOUND var -------------------------- +{ + const r = S.makeSchema({}, { recs: 'uniform array, 4096>', data: 'read array>' }, { readOnly: ['recs'] }); + const decl = name => (r.wgsl.match(new RegExp(`var(<[^>]*>)? ${name}: [^;]*;`)) || [''])[0]; + check('`uniform T` binds var, the marker gone from the type', decl('recs'), 'var recs: array, 4096>;'); + check('...readOnly naming it changes nothing (a uniform is read-only already)', /storage/.test(decl('recs')), false); + check('...beside a storage buffer as before', decl('data'), 'var data: array>;'); + check('...the bindings numbered in order', [...r.wgsl.matchAll(/@binding\((\d+)\)/g)].map(m => +m[1]), [0, 1]); + // the bind-time check (DIAG) then asks its buffer for UNIFORM usage, not STORAGE: it refused the first draft's + SOURCE && check('...marked a uniform in the layout, the storage buffer not', [r._resourceLayout?.recs?._isUniform, r._resourceLayout?.data?._isUniform], [true, false]); +} + // --- pipeline: generated entry point, dispatch sizing, resource binding ----- { let lastCode = null, dispatched = null; @@ -157,6 +199,36 @@ const layoutOf = uniforms => { check('run() divides item count by the workgroup size', dispatched, [16, 1, 1]); p.dispatch(1000); check('dispatch() still counts workgroups', dispatched, [1000, 1, 1]); + + // --- the compute pipeline cache: the same text on the same device compiles once ----------- + // (a sync pipeline is entered only once its error scopes come back clean, so the second request + // has to come after they have settled) + let compiles = 0, asyncCompiles = 0; + const createComputePipeline = S.device.createComputePipeline; + S.device.createComputePipeline = d => (compiles++, createComputePipeline(d)); + S.device.createComputePipelineAsync = async d => (asyncCompiles++, createComputePipeline(d)); + const text = 'if (gid.x < UB.n) { a[gid.x] = 1.0; }'; + const mk = opts => S.makeCompute('', text, { n: 'u32' }, { a: 'array' }, { wg: [64, 1, 1], ...opts }); + const c1 = mk(); + await new Promise(r => setTimeout(r, 0)); + const c2 = mk(); + check('the same compute text compiles once', [compiles, c1.cached, c2.cached], SOURCE ? [1, false, true] : [2, false, false]); + SOURCE && check('...and the two handles share the pipeline', c1.pipeline === c2.pipeline, true); + const before = compiles; + mk({ cache: false }); + check('cache: false compiles anyway', compiles, before + 1); + const a1 = mk({ async: true, wg: [32, 1, 1] }); + check('async: no pipeline until ready', a1.pipeline, 0); + a1.setResources({ a: A }); // parked until the pipeline arrives + let early = ''; + try { a1.dispatch(1); } catch (e) { early = e.message; } + check('...and dispatching before then says so', /still compiling/.test(early), true); + const a2 = mk({ async: true, wg: [32, 1, 1] }); + await a1.ready; await a2.ready; + check('...a second async request waits on the first compile', [asyncCompiles, a2.cached], [1, true]); + check('...ready fills the pipeline', !!a1.pipeline, true); + const m = mk({ moduleOnly: true }); + check('moduleOnly hands back the text and a module, and compiles nothing', [typeof m.wgsl, !!m.module, compiles], ['string', true, before + 1]); } // --- readTexture: rows come back tightly packed, without the 256-byte padding ------------- diff --git a/tools/build-min.mjs b/tools/build-min.mjs index 33c2b05..2b726e6 100644 --- a/tools/build-min.mjs +++ b/tools/build-min.mjs @@ -182,6 +182,7 @@ const ERRORS = { 21: 'depth: target size unknown — pass a GPUTexture to drawTo, or supply depth.texture', 22: 'unknown resource name in setResources', 23: 'init: the CSS selector matched nothing', + 24: 'pipeline still compiling (made with { async: true }); await its ready promise', }; // ── argv & config ───────────────────────────────────────────────────────────────────────────── @@ -442,7 +443,10 @@ let packed = code; const OPEN = iife ? 'globalThis.WEBGPU=()=>{' : 'export let WEBGPU=()=>{'; if (!packed.includes(OPEN)) { const alias = packed.match(iife ? /globalThis\.WEBGPU=([A-Za-z_$][\w$]*);?/ : /export\{([A-Za-z_$][\w$]*) as WEBGPU\};?/); - const decl = alias && new RegExp(`\\b(?:let|var) ${escapeRe(alias[1])}=\\(\\)=>\\{`); + // esbuild may fold a module-level declaration before the factory into the same statement + // (`let a=new WeakMap,yt=()=>{`): the declarators before the factory's are split off into a + // statement of their own, so the factory's opening is still exactly OPEN. + const decl = alias && new RegExp(`\\b(let|var) ((?:[^;{}()]|\\(\\))*?,)?${escapeRe(alias[1])}=\\(\\)=>\\{`); // The alias must appear exactly twice — declaration and export/assignment — or the splice // would orphan a reference. Fail loudly rather than ship a broken artifact. const uses = alias ? (packed.match(new RegExp(`\\b${escapeRe(alias[1])}\\b`, 'g')) ?? []).length : 0; @@ -450,7 +454,7 @@ if (!packed.includes(OPEN)) { console.error(`build-min: could not rewrite the WEBGPU export to \`${OPEN}\` — the minified export changed shape.`); process.exit(1); } - packed = packed.replace(alias[0], '').replace(decl, OPEN); + packed = packed.replace(alias[0], '').replace(decl, (_, kw, before) => (before ? `${kw} ${before.slice(0, -1)};` : '') + OPEN); } // ── terser second pass: remove what esbuild leaves behind by design ──────────────────────────