import { reflectStages, reflectWGSL } from "./reflection.js"; import { resolveShaders } from "./pipeline-cache.js"; import { beginFrame, endFrame } from "./context.js"; import { createTexture, isGpuTexture } from "./texture.js"; import { debugCounters, debugGroupsEnabled, debugLog, recordCacheAccess, trackPassTiming, } from "./debug.js"; import { buildBindGroups, pipelineLayout } from "./bind-groups.js"; import { resolveVertexState } from "./vertex-layout.js"; import { commandsState, frameState } from "./state.js"; import type { ActiveComputePass, ActivePass, CommandsState, FrameState, } from "./state.js"; import type { BundleCommand, Command, ComputeCommand, ComputePipeline, ComputePassDescriptor, DepthStencilAttachment, RenderCommand, GpuContext, GpuTexture, RenderPassDescriptor, RenderPipeline, } from "./types.js"; // ─── Render pass resolution ────────────────────────────────────────────────── /** * The context-managed canvas-sized depth/stencil texture, recreated on demand * when the canvas size changes. Restores the WebGL-era "the canvas has a depth * buffer" ergonomics for canvas-targeting passes. */ function canvasDepthStencilTexture(ctx: GpuContext): GpuTexture { if (!ctx.depthStencilFormat) { throw new Error( "pex-gpu: the canvas has no depth/stencil buffer — the context was created with depth and stencil disabled", ); } const state = commandsState(ctx); if ( state.canvasDepthStencil && (state.canvasDepthStencil.width !== ctx.width || state.canvasDepthStencil.height !== ctx.height) ) { state.canvasDepthStencil.dispose(); state.canvasDepthStencil = null; } state.canvasDepthStencil ??= createTexture(ctx, { width: ctx.width, height: ctx.height, format: ctx.depthStencilFormat, label: "pex-gpu canvas depth/stencil", }); return state.canvasDepthStencil; } function beginPass( ctx: GpuContext, frame: FrameState, pass: RenderPassDescriptor = {}, label?: string, ): ActivePass { const colorFormats: (GPUTextureFormat | null)[] = []; let sampleCount = 1; // Default to a single canvas attachment unless colorAttachments: [] is explicit. const colors = pass.colorAttachments ?? [{}]; const colorAttachments = colors.map((attachment, index) => { const clearValue = attachment.clearValue ?? (index === 0 ? pass.clearValue : undefined); const texture = attachment.texture; colorFormats.push(texture?.format ?? ctx.format); sampleCount = Math.max(sampleCount, texture?.sampleCount ?? 1); const resolveTarget = attachment.resolveTarget === "canvas" ? frame.canvasView : attachment.resolveTarget?.view; return { view: attachment.view ?? texture?.view ?? frame.canvasView, ...(resolveTarget && { resolveTarget }), loadOp: attachment.loadOp ?? (clearValue ? "clear" : "load"), storeOp: attachment.storeOp ?? (resolveTarget ? "discard" : "store"), ...(clearValue && { clearValue }), } satisfies GPURenderPassColorAttachment; }); // Canvas-targeting passes attach the managed canvas depth/stencil // implicitly; clear value shorthands without an attachment imply it too. const hasClearShorthand = pass.depthClearValue !== undefined || pass.stencilClearValue !== undefined; const targetsCanvas = colors.every( (attachment) => !attachment.texture && !attachment.view, ); const depthStencilInput = pass.depthStencilAttachment ?? (targetsCanvas && (ctx.depthStencilFormat || hasClearShorthand) ? "canvas" : undefined); if (!depthStencilInput && hasClearShorthand) { throw new Error( "pex-gpu: depthClearValue/stencilClearValue on an offscreen pass requires an explicit depth/stencil attachment", ); } let depthStencilAttachment: GPURenderPassDepthStencilAttachment | undefined; let depthStencilFormat: GPUTextureFormat | undefined; let depthReadOnly = false; if (depthStencilInput) { const isCanvasDepthStencil = depthStencilInput === "canvas"; const resolved = isCanvasDepthStencil ? canvasDepthStencilTexture(ctx) : depthStencilInput; const depthStencil: DepthStencilAttachment = isGpuTexture(resolved) ? { texture: resolved } : resolved; const depthClearValue = depthStencil.depthClearValue ?? pass.depthClearValue; const stencilClearValue = depthStencil.stencilClearValue ?? pass.stencilClearValue; depthStencilFormat = depthStencil.texture.format; // "stencil8" has no depth aspect: depth ops must not be provided const hasDepth = depthStencilFormat.startsWith("depth"); depthReadOnly = hasDepth && (depthStencil.depthReadOnly ?? false); // The managed canvas depth/stencil clears at its first use each frame // and loads in later passes const autoClear = isCanvasDepthStencil && !frame.canvasDepthStencilCleared; frame.canvasDepthStencilCleared ||= isCanvasDepthStencil; sampleCount = Math.max(sampleCount, depthStencil.texture.sampleCount); depthStencilAttachment = { view: depthStencil.view ?? depthStencil.texture.view, ...(hasDepth && (depthReadOnly ? { depthReadOnly: true } : { depthLoadOp: depthStencil.depthLoadOp ?? (depthClearValue !== undefined || autoClear ? "clear" : "load"), depthStoreOp: depthStencil.depthStoreOp ?? "store", depthClearValue: depthClearValue ?? 1, })), // Formats with a stencil aspect require stencil ops (or readOnly) ...(depthStencilFormat.includes("stencil") && (depthStencil.stencilReadOnly ? { stencilReadOnly: true } : { stencilLoadOp: depthStencil.stencilLoadOp ?? (stencilClearValue !== undefined || autoClear ? "clear" : "load"), stencilStoreOp: depthStencil.stencilStoreOp ?? "store", stencilClearValue: stencilClearValue ?? 0, })), }; } const key = `${colorFormats.join(",")}|${depthStencilFormat ?? ""}|${sampleCount}${depthReadOnly ? "|r" : ""}`; const passLabel = pass.label ?? label ?? "pex-gpu pass"; debugCounters(ctx.device).frame.renderPasses++; debugLog( ctx.device, "commands", () => `beginRenderPass "${passLabel}" [${key}]`, ); const timestampWrites = trackPassTiming(ctx, passLabel, "render", pass.timestampWrites) ?? pass.timestampWrites; const encoder = frame.encoder.beginRenderPass({ label: passLabel, colorAttachments, ...(depthStencilAttachment && { depthStencilAttachment }), ...(timestampWrites && { timestampWrites }), }); return { encoder, colorFormats, depthStencilFormat, depthReadOnly, sampleCount, key, }; } // ─── Draw encoding ─────────────────────────────────────────────────────────── /** * Marks `def` failed when `create` throws, so a source reflection can't parse * throws once rather than at every submit. */ function failOnThrow( state: CommandsState, def: RenderPipeline | ComputePipeline, create: () => T, ): T { try { return create(); } catch (error) { state.failedPipelines.add(def); throw error; } } function draw(ctx: GpuContext, active: ActivePass, cmd: RenderCommand): void { const { pipeline: def } = cmd; if (!def) { throw new Error( `pex-gpu: render command${cmd.label ? ` "${cmd.label}"` : ""} has no pipeline`, ); } const state = commandsState(ctx); if (state.failedPipelines.has(def)) return; // Reflection and pipeline layout are resolved once per pipeline object // (its WGSL sources are assumed immutable). A hit here is the reflection // this draw reused, and is recorded as one: the request never reaches the // source-keyed cache below, which would otherwise only ever see misses. let reflectionCreated = false; const cached = state.renderPipelines.getOrInsertComputed(def, () => { reflectionCreated = true; return failOnThrow(state, def, () => { const { vertex: vertexSource, fragment: fragmentSource } = resolveShaders(def); const reflection = reflectStages( ctx.device, vertexSource, fragmentSource, ); const { layout, key } = pipelineLayout(ctx, reflection); return { vertexSource, fragmentSource, reflection, layout, layoutKey: key, variants: new Map(), }; }); }); if (!reflectionCreated) recordCacheAccess(ctx.device, "shaderReflections", null); const { reflection, vertexSource, fragmentSource } = cached; const vertexState = resolveVertexState( reflection.vertexInputs, cmd.attributes ?? {}, ); const stripIndexFormat = cmd.indices && def.topology?.endsWith("strip") ? cmd.indices.indexFormat : undefined; // Everything that can change without the pipeline object identity changing. const variantKey = `${active.key}|${vertexState.key}|${def.topology ?? ""},${def.cullMode ?? ""},${def.frontFace ?? ""},${stripIndexFormat ?? ""},${def.depthWriteEnabled ?? ""},${def.depthCompare ?? ""},${def.vertexEntryPoint ?? ""},${def.fragmentEntryPoint ?? ""},${def.alphaToCoverage ?? ""}` + (def.constants ? `|c${JSON.stringify(def.constants)}` : "") + // Object-valued state (blend, stencil) is grouped and JSON-stringified, // and only appended when set, so the common case's key stays cheap to build. (def.blend || def.writeMask !== undefined || def.stencilFront || def.stencilBack || def.stencilReadMask !== undefined || def.stencilWriteMask !== undefined || def.depthBias !== undefined || def.depthBiasSlopeScale !== undefined || def.depthBiasClamp !== undefined ? `|${JSON.stringify([ def.blend, def.writeMask, def.stencilFront, def.stencilBack, def.stencilReadMask, def.stencilWriteMask, def.depthBias, def.depthBiasSlopeScale, def.depthBiasClamp, ])}` : ""); let pipelineCreated = false; const pipeline = cached.variants.getOrInsertComputed(variantKey, () => { pipelineCreated = true; const hasDepth = active.depthStencilFormat?.startsWith("depth"); const blend = def.blend; const writeMask = def.writeMask; const targets: (GPUColorTargetState | null)[] = fragmentSource ? active.colorFormats.map((format, index) => { if (!format) return null; const targetBlend = Array.isArray(blend) ? blend[index] : blend; const targetWriteMask = Array.isArray(writeMask) ? writeMask[index] : writeMask; return { format, ...(targetBlend && { blend: targetBlend }), ...(targetWriteMask !== undefined && { writeMask: targetWriteMask, }), }; }) : []; return ctx.pipelineCache.getRenderPipeline({ vertex: vertexSource, ...(fragmentSource && { fragment: fragmentSource }), ...(def.vertexEntryPoint && { vertexEntryPoint: def.vertexEntryPoint }), ...(def.fragmentEntryPoint && { fragmentEntryPoint: def.fragmentEntryPoint, }), buffers: vertexState.layouts, targets: targets as GPUColorTargetState[], ...(def.constants && { constants: def.constants }), primitive: { topology: def.topology ?? "triangle-list", cullMode: def.cullMode ?? "none", frontFace: def.frontFace ?? "ccw", ...(stripIndexFormat && { stripIndexFormat }), }, ...(active.depthStencilFormat && { depthStencil: { format: active.depthStencilFormat, // Read-only and depth-less ("stencil8") attachments require // non-writing, non-comparing pipelines depthWriteEnabled: hasDepth && !active.depthReadOnly ? (def.depthWriteEnabled ?? false) : false, depthCompare: hasDepth ? (def.depthCompare ?? (def.depthWriteEnabled ? "less-equal" : "always")) : "always", ...(def.depthBias !== undefined && { depthBias: def.depthBias }), ...(def.depthBiasSlopeScale !== undefined && { depthBiasSlopeScale: def.depthBiasSlopeScale, }), ...(def.depthBiasClamp !== undefined && { depthBiasClamp: def.depthBiasClamp, }), ...(def.stencilFront && { stencilFront: def.stencilFront }), ...(def.stencilBack && { stencilBack: def.stencilBack }), ...(def.stencilReadMask !== undefined && { stencilReadMask: def.stencilReadMask, }), ...(def.stencilWriteMask !== undefined && { stencilWriteMask: def.stencilWriteMask, }), }, }), ...(active.sampleCount > 1 && { multisample: { count: active.sampleCount, // Only meaningful when multisampled, so it rides along with count // rather than being a state of its own. ...(def.alphaToCoverage && { alphaToCoverageEnabled: true }), }, }), layout: cached.layout, layoutKey: cached.layoutKey, ...(def.label && { label: def.label }), }); }); if (!pipelineCreated) recordCacheAccess(ctx.device, "renderPipelines", null); // A pipeline object fails as a whole: a failing variant is almost always its // shader, which every variant shares. if (ctx.pipelineCache.hasFailed(pipeline)) { state.failedPipelines.add(def); return; } const pass = active.encoder; debugCounters(ctx.device).frame.draws++; const group = debugGroupsEnabled(ctx.device) ? (cmd.label ?? def.label) : undefined; if (group) pass.pushDebugGroup(group); try { pass.setPipeline(pipeline); if (cmd.viewport) pass.setViewport(...cmd.viewport, 0, 1); if (cmd.scissor) pass.setScissorRect(...cmd.scissor); if (cmd.stencilReference !== undefined) { pass.setStencilReference(cmd.stencilReference); } for (const { index, bindGroup, dynamicOffsets } of buildBindGroups( ctx, reflection, cmd.uniforms ?? {}, cmd.bindGroups, )) { pass.setBindGroup(index, bindGroup, dynamicOffsets); } for (const [slot, binding] of vertexState.bindings.entries()) { pass.setVertexBuffer(slot, binding.buffer.buffer, binding.offset); } if (cmd.indices) { pass.setIndexBuffer( cmd.indices.buffer, cmd.indices.indexFormat ?? "uint16", ); } if (cmd.indirect) { // GpuBuffer has a .buffer property too — discriminate on .id const indirect = "id" in cmd.indirect ? { buffer: cmd.indirect, offset: 0 } : { offset: 0, ...cmd.indirect }; debugLog( ctx.device, "commands", () => `draw${cmd.indices ? "IndexedIndirect" : "Indirect"}${cmd.label ? ` "${cmd.label}"` : ""}`, ); if (cmd.indices) { pass.drawIndexedIndirect(indirect.buffer.buffer, indirect.offset); } else { pass.drawIndirect(indirect.buffer.buffer, indirect.offset); } return; } const count = cmd.count ?? cmd.indices?.length ?? vertexState.inferredCount; if (count === undefined) { throw new Error( `pex-gpu: cannot infer draw count${cmd.label ? ` for "${cmd.label}"` : ""}; set count, indices or indirect`, ); } debugLog( ctx.device, "commands", () => `draw${cmd.indices ? "Indexed" : ""}${cmd.label ? ` "${cmd.label}"` : ""} count=${count} instances=${cmd.instanceCount ?? 1}`, ); if (cmd.indices) { pass.drawIndexed( count, cmd.instanceCount ?? 1, cmd.first ?? 0, cmd.baseVertex ?? 0, cmd.firstInstance ?? 0, ); } else { pass.draw( count, cmd.instanceCount ?? 1, cmd.first ?? 0, cmd.firstInstance ?? 0, ); } } finally { if (group) pass.popDebugGroup(); } } // ─── Compute encoding ──────────────────────────────────────────────────────── /** * The compute counterpart of {@link beginPass}. Compute passes carry no * descriptor state beyond a label and optional timestamp writes — no * attachments, formats or sample count. The command label names the pass when * the descriptor doesn't. */ function beginComputePass( ctx: GpuContext, frame: FrameState, pass: ComputePassDescriptor = {}, label?: string, ): ActiveComputePass { const passLabel = pass.label ?? label ?? "pex-gpu compute pass"; debugCounters(ctx.device).frame.computePasses++; debugLog(ctx.device, "commands", () => `beginComputePass "${passLabel}"`); const timestampWrites = trackPassTiming(ctx, passLabel, "compute", pass.timestampWrites) ?? pass.timestampWrites; return { encoder: frame.encoder.beginComputePass({ label: passLabel, ...(timestampWrites && { timestampWrites }), }), }; } function dispatchCompute( ctx: GpuContext, active: ActiveComputePass, cmd: ComputeCommand, ): void { const { pipeline: def } = cmd; const state = commandsState(ctx); if (state.failedPipelines.has(def)) return; let reflectionCreated = false; const cached = state.computePipelines.getOrInsertComputed(def, () => { reflectionCreated = true; return failOnThrow(state, def, () => { const reflection = reflectWGSL(ctx.device, def.compute); const { layout, key } = pipelineLayout(ctx, reflection); return { reflection, layout, layoutKey: key, variants: new Map() }; }); }); if (!reflectionCreated) recordCacheAccess(ctx.device, "shaderReflections", null); const { reflection } = cached; const variantKey = `${def.entryPoint ?? ""}${ def.constants ? `|${JSON.stringify(def.constants)}` : "" }`; let pipelineCreated = false; const pipeline = cached.variants.getOrInsertComputed(variantKey, () => { pipelineCreated = true; return ctx.pipelineCache.getComputePipeline({ compute: def.compute, ...(def.entryPoint && { entryPoint: def.entryPoint }), ...(def.constants && { constants: def.constants }), layout: cached.layout, layoutKey: cached.layoutKey, ...(def.label && { label: def.label }), }); }); if (!pipelineCreated) recordCacheAccess(ctx.device, "computePipelines", null); if (ctx.pipelineCache.hasFailed(pipeline)) { state.failedPipelines.add(def); return; } const pass = active.encoder; debugCounters(ctx.device).frame.dispatches++; debugLog( ctx.device, "commands", () => `dispatch${cmd.label ? ` "${cmd.label}"` : ""} ${ typeof cmd.dispatch === "object" && !Array.isArray(cmd.dispatch) ? "indirect" : JSON.stringify(cmd.dispatch) }`, ); const group = debugGroupsEnabled(ctx.device) ? (cmd.label ?? def.label) : undefined; if (group) pass.pushDebugGroup(group); try { pass.setPipeline(pipeline); for (const { index, bindGroup, dynamicOffsets } of buildBindGroups( ctx, reflection, cmd.uniforms ?? {}, cmd.bindGroups, )) { pass.setBindGroup(index, bindGroup, dynamicOffsets); } if (typeof cmd.dispatch === "number") { pass.dispatchWorkgroups(cmd.dispatch); } else if (Array.isArray(cmd.dispatch)) { pass.dispatchWorkgroups(...cmd.dispatch); } else { pass.dispatchWorkgroupsIndirect( cmd.dispatch.buffer.buffer, cmd.dispatch.offset ?? 0, ); } } finally { if (group) pass.popDebugGroup(); } } // ─── Public API ────────────────────────────────────────────────────────────── /** * Whether a pipeline object failed: its shader couldn't be reflected, or one of * its GPU pipelines failed validation. Submits using it are skipped, so a * fallback needs a new pipeline object. Validation failures are known * asynchronously, after the first submits. */ export function pipelineFailed( ctx: GpuContext, pipeline: RenderPipeline | ComputePipeline, ): boolean { return commandsState(ctx).failedPipelines.has(pipeline); } const mergeCommand = ( base: T, override: Partial, ): T => ({ ...base, ...override, // Uniforms merge shallowly (rather than override replacing the whole // object) so batch variants only need to specify what changes. ...((base.uniforms || override.uniforms) && { uniforms: { ...base.uniforms, ...override.uniforms }, }), }) as T; /** Run a pass scope callback with the frame's active pass slot swapped in. */ const withPassScope = ( frame: FrameState, slot: K, active: NonNullable, scope: () => void, ): void => { const previous = frame[slot]; frame[slot] = active; try { scope(); } finally { frame[slot] = previous; } }; /** * Submit a command: a render command (one or more draws, or a pass-only clear), * a compute dispatch, or render bundles. * * With no {@link beginFrame}/{@link endFrame} segment open, opens and submits one * for just this call — no ceremony needed for one-off submits. Wrap multiple * calls in an explicit segment to share one command buffer and uniform flush * instead of paying per call. * * ```js * // single draw (opens and closes its own render pass) * gpu.submit(ctx, { pass, pipeline, attributes, indices, uniforms }); * * // pass scope: nested submits render into the same pass * gpu.submit(ctx, { pass }, () => { * gpu.submit(ctx, drawCmd); * gpu.submit(ctx, otherDrawCmd); * }); * * // batch: draw variants of a command inside one pass * gpu.submit(ctx, drawCmd, [ * { uniforms: { model: a } }, * { uniforms: { model: b } }, * ]); * * // pass only: an empty pass clears its attachments * gpu.submit(ctx, { pass: { clearValue: [0, 0, 0, 1] } }); * * // compute * gpu.submit(ctx, { pipeline: { compute }, uniforms, dispatch: [64] }); * * // compute batch: dispatch variants inside one compute pass * gpu.submit(ctx, simulateCmd, [ * { uniforms: { step: a } }, * { uniforms: { step: b } }, * ]); * * // compute pass scope: nested compute submits dispatch into the same pass * gpu.submit(ctx, simulateCmd, () => { * gpu.submit(ctx, diffuseCmd); * }); * ``` */ export function submit( ctx: GpuContext, cmd: RenderCommand, passScopeOrBatch?: (() => void) | Partial[], ): void; /** * Compute commands batch dispatch variants into one compute pass, or keep it * open with a pass scope callback for nested compute submits. */ export function submit( ctx: GpuContext, cmd: ComputeCommand, passScopeOrBatch?: (() => void) | Partial[], ): void; /** * Bundle commands replay pre-recorded bundles; a pass scope callback keeps * their pass open for nested submits. */ export function submit( ctx: GpuContext, cmd: BundleCommand, passScope?: () => void, ): void; export function submit( ctx: GpuContext, cmd: Command, passScopeOrBatch?: (() => void) | Partial[] | Partial[], ): void { const commands = commandsState(ctx); const ownsFrame = !commands.frame; if (ownsFrame) { beginFrame( ctx, cmd.label ? `pex-gpu submit "${cmd.label}"` : "pex-gpu submit", ); } const frame = frameState(ctx); try { if ("dispatch" in cmd) { if (frame.activePass) { throw new Error( "pex-gpu: cannot dispatch compute inside a render pass scope", ); } if (cmd.pass && frame.activeComputePass) { throw new Error( "pex-gpu: cannot begin a compute pass inside another compute pass scope", ); } const ownsPass = !frame.activeComputePass; const active = frame.activeComputePass ?? beginComputePass(ctx, frame, cmd.pass, cmd.label); try { if (Array.isArray(passScopeOrBatch)) { for (const override of passScopeOrBatch as Partial[]) { dispatchCompute(ctx, active, mergeCommand(cmd, override)); } } else { dispatchCompute(ctx, active, cmd); } if (typeof passScopeOrBatch === "function") { withPassScope(frame, "activeComputePass", active, passScopeOrBatch); } } finally { if (ownsPass) active.encoder.end(); } } else { if (frame.activeComputePass) { throw new Error( "pex-gpu: cannot begin a render pass inside a compute pass scope", ); } if (cmd.pass && frame.activePass) { throw new Error( "pex-gpu: cannot begin a render pass inside another render pass scope", ); } const ownsPass = !frame.activePass; const active = frame.activePass ?? beginPass(ctx, frame, cmd.pass, cmd.label); try { if ("bundles" in cmd) { if (Array.isArray(passScopeOrBatch)) { throw new TypeError( "pex-gpu: bundle commands do not support batch variants", ); } debugCounters(ctx.device).frame.bundles += ( cmd as BundleCommand ).bundles.length; debugLog( ctx.device, "commands", () => `executeBundles${cmd.label ? ` "${cmd.label}"` : ""} (${(cmd as BundleCommand).bundles.length})`, ); active.encoder.executeBundles((cmd as BundleCommand).bundles); } else if (Array.isArray(passScopeOrBatch)) { for (const override of passScopeOrBatch as Partial[]) { draw(ctx, active, mergeCommand(cmd, override)); } } else if (cmd.pipeline) { draw(ctx, active, cmd); } if (typeof passScopeOrBatch === "function") { withPassScope(frame, "activePass", active, passScopeOrBatch); } } finally { if (ownsPass) active.encoder.end(); } } } finally { if (ownsFrame) endFrame(ctx); } }