Something went wrong. Try again.
A WebGPU retro game engine for TypeScript, inspired by RetroBlit. Build pixel-perfect 2D games with a clean, fantasy-console-style API.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226import type { Vector2i } from '../../utils/Vector2i';import type { Effect, EffectTier } from './Effect';import { FULLSCREEN_VS_WGSL } from './fullscreenVS';
/** * Sampler magnification/minification mode used by a fullscreen effect. * * - `'linear'` - bilinear filtering (default for display-tier effects). * - `'nearest'` - point sampling, palette-friendly for pixel-tier effects. */export type EffectSamplerFilter = 'nearest' | 'linear';
/** * Base class for typical fullscreen post-processing effects. * * Owns the standard pipeline / uniform buffer / sampler / bind-group cache so * subclasses focus on the WGSL fragment shader and uniform layout. The bind * group layout always exposes: * * - `@group(0) @binding(0)` - uniform block (any subclass-defined `Params`). * - `@group(0) @binding(1)` - source `texture_2d<f32>` (named `src`). * - `@group(0) @binding(2)` - `sampler` (named `samp`). * * Subclasses provide: * - {@link tier} * - {@link label} - debug label for GPU resources. * - {@link uniformBytes} - byte size of the uniform block (multiple of 16). * - {@link fragmentShader} - WGSL fragment shader source. * - {@link writeUniforms} - populates `uniformData` from public params. * - Optionally {@link samplerFilter} (defaults to `'linear'`). */export abstract class FullscreenEffect implements Effect { /** Tier this effect belongs to (`'pixel'` or `'display'`). */ abstract readonly tier: EffectTier;
/** * Sampler filter mode. Defaults to `'linear'` (smooth - appropriate for * display-tier effects). Pixel-tier effects can override to `'nearest'` to * preserve palette colors during sampling. */ protected readonly samplerFilter: EffectSamplerFilter = 'linear';
/** Human-readable label for GPU debug names. */ protected abstract readonly label: string;
/** Byte size of the uniform block. Must be a multiple of 16. */ protected abstract readonly uniformBytes: number;
/** WGSL fragment shader source. Must declare `Params`, `src`, `samp`. */ protected abstract readonly fragmentShader: string; protected device: GPUDevice | null = null;
// #region GPU State protected pipeline: GPURenderPipeline | null = null; protected uniformBuffer: GPUBuffer | null = null; protected sampler: GPUSampler | null = null; protected bindGroupLayout: GPUBindGroupLayout | null = null; /** * Backing array for the uniform block. Lazily allocated in {@link init} * after subclass constants are set on `this`. */ protected uniformData: Float32Array<ArrayBuffer> | null = null; /** * Per-source-view bind group cache. The chain ping-pongs between two * stable views, so a `WeakMap` keyed by view is safe. * Reassigned on every `dispose()` so stale bind groups are not reused * after a re-init cycle. */ private bindGroups = new WeakMap<GPUTextureView, GPUBindGroup>();
/** * Creates the GPU pipeline, uniform buffer, sampler, and bind-group layout. * * @param device - WebGPU device used for resource creation. * @param format - Color attachment format (matches the chain swap chain). * @param _displaySize - Source render target resolution. Most effects ignore * this and use the per-frame `sourceSize` from {@link updateUniforms}. */ init(device: GPUDevice, format: GPUTextureFormat, _displaySize: Vector2i): void { if (this.uniformBytes <= 0 || this.uniformBytes % 16 !== 0) { throw new Error( `FullscreenEffect: uniformBytes must be a positive multiple of 16 (got ${this.uniformBytes}).`, ); }
this.device = device; this.uniformData = new Float32Array(new ArrayBuffer(this.uniformBytes));
const module = device.createShaderModule({ label: `${this.label} Shader`, code: FULLSCREEN_VS_WGSL + this.fragmentShader, });
this.pipeline = device.createRenderPipeline({ label: `${this.label} Pipeline`, layout: 'auto', vertex: { module, entryPoint: 'vs_main' }, fragment: { module, entryPoint: 'fs_main', targets: [{ format }] }, primitive: { topology: 'triangle-list', cullMode: 'none' }, });
this.bindGroupLayout = this.pipeline.getBindGroupLayout(0);
this.uniformBuffer = device.createBuffer({ label: `${this.label} Uniform Buffer`, size: this.uniformBytes, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, });
this.sampler = device.createSampler({ label: `${this.label} Sampler`, magFilter: this.samplerFilter, minFilter: this.samplerFilter, addressModeU: 'clamp-to-edge', addressModeV: 'clamp-to-edge', }); }
// #endregion
// #region Effect lifecycle
/** * Calls {@link writeUniforms} then uploads the uniform block to the GPU. * * @param deltaMs - Wall-clock milliseconds since the previous frame. * @param sourceSize - Pixel dimensions of the source texture for this pass. */ updateUniforms(deltaMs: number, sourceSize: Vector2i): void { if (!this.device || !this.uniformBuffer || !this.uniformData) { return; }
this.writeUniforms(deltaMs, sourceSize); this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData); }
/** * Encodes the fullscreen pass that samples {@link sourceView} and writes * the result into {@link destView}. * * @param encoder - Active command encoder owned by the renderer. * @param sourceView - View of the texture to sample from. * @param destView - View of the texture to render into. */ encodePass(encoder: GPUCommandEncoder, sourceView: GPUTextureView, destView: GPUTextureView): void { if (!this.pipeline) { return; }
const bindGroup = this.getOrCreateBindGroup(sourceView); const pass = encoder.beginRenderPass({ label: `${this.label} Pass`, colorAttachments: [ { view: destView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store', }, ], });
pass.setPipeline(this.pipeline); pass.setBindGroup(0, bindGroup); pass.draw(3, 1, 0, 0); pass.end(); }
/** * Releases GPU resources owned by this effect. Safe to call multiple times. */ dispose(): void { this.uniformBuffer?.destroy(); this.uniformBuffer = null; this.pipeline = null; this.bindGroupLayout = null; this.sampler = null; this.device = null; this.uniformData = null; this.bindGroups = new WeakMap(); }
/** Writes the per-frame uniform values into {@link uniformData}. */ protected abstract writeUniforms(deltaMs: number, sourceSize: Vector2i): void;
// #endregion
// #region Private Helpers
/** * Returns the bind group for the supplied source view, creating one on * first use and caching by view identity. * * @param sourceView - View of the source texture to sample. * @returns Cached or newly created bind group. */ private getOrCreateBindGroup(sourceView: GPUTextureView): GPUBindGroup { const cached = this.bindGroups.get(sourceView);
if (cached) { return cached; }
if (!this.device || !this.bindGroupLayout || !this.uniformBuffer || !this.sampler) { throw new Error(`${this.label}.encodePass: effect was not initialized.`); }
const bindGroup = this.device.createBindGroup({ label: `${this.label} Bind Group`, layout: this.bindGroupLayout, entries: [ { binding: 0, resource: { buffer: this.uniformBuffer } }, { binding: 1, resource: sourceView }, { binding: 2, resource: this.sampler }, ], });
this.bindGroups.set(sourceView, bindGroup);
return bindGroup; }
// #endregion}