commit 2c5ac481a4bedba9b9b3d3af3f7177786f059386 Author: rowan Date: Tue Apr 15 05:34:18 2025 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d570088 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ + diff --git a/client b/client new file mode 100755 index 0000000..025ed99 --- /dev/null +++ b/client @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +servo --pref dom_webgpu_enabled=true http://localhost:8000 diff --git a/index.html b/index.html new file mode 100644 index 0000000..601b2c6 --- /dev/null +++ b/index.html @@ -0,0 +1,30 @@ + + + + + + + WebGPU + + + + + + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..5509dca --- /dev/null +++ b/index.js @@ -0,0 +1,169 @@ +import { GraphicsDevice } from './src/core/graphics-device.js' +import { PowerPreference } from './src/enum.js' + +async function main() { + const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('webgpu-canvas')) + + if (!canvas) { + console.error("Canvas element not found!") + return + } + + canvas.width = 800 + canvas.height = 600 + + + const graphicsDevice = await GraphicsDevice.build() + .withCanvas(canvas) + .withAdapter({ powerPreference: PowerPreference.HighPerformance }) + .build() + + const success = await graphicsDevice.initialize() + + if (!success) { + console.error("Failed to initialize WebGPU.") + document.body.innerHTML = "WebGPU initialization failed. Please use a supported browser and ensure hardware acceleration is enabled." + return + } + + const shaderCode = ` + @vertex + fn vs_main(@location(0) in_pos : vec3) -> @builtin(position) vec4 { + return vec4(in_pos, 1.0); + } + + @fragment + fn fs_main() -> @location(0) vec4 { + return vec4(0.0, 0.5, 1.0, 1.0); + } + ` + + const shaderModule = graphicsDevice.createShaderModule(shaderCode, 'TriangleShader') + + const vertices = new Float32Array([ + 0.0, 0.5, 0.0, + -0.5, -0.5, 0.0, + 0.5, -0.5, 0.0 + ]) + + const vertexBuffer = graphicsDevice.createBuffer( + { + label: 'TriangleVertexBuffer', + size: vertices.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }, + vertices + ) + + const matrixSize = 4 * 4 * Float32Array.BYTES_PER_ELEMENT + const matrixData = new Float32Array(16) + + matrixData[0] = 1 + matrixData[5] = 1 + matrixData[10] = 1 + matrixData[15] = 1 + + const uniformBuffer = graphicsDevice.createBuffer( + { + label: 'SceneUniformsBuffer', + size: matrixSize, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }, + matrixData + ) + + const bindings = [{ + binding: 0, + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: /** @type {GPUBufferBindingLayout} */ ({ type: 'uniform' }) + }] + + const bindGroupLayout = graphicsDevice.createBindGroupLayout(bindings, 'UniformLayout') + + + const pipelineLayout = graphicsDevice.createPipelineLayout([bindGroupLayout], 'PipelineLayout') + + const pipeline = graphicsDevice.createRenderPipeline({ + label: 'TrianglePipeline', + layout: pipelineLayout, + vertex: { + module: shaderModule.handle, + entryPoint: 'vs_main', + buffers: [ + { + arrayStride: 3 * 4, + attributes: [ + { shaderLocation: 0, offset: 0, format: 'float32x3' } + ] + } + ] + }, + fragment: { + module: shaderModule.handle, + entryPoint: 'fs_main', + targets: [ + { format: graphicsDevice.swapChain.format } + ] + }, + primitive: { + topology: 'triangle-list', + }, + }) + + /** @type {Array} */ + const entries = [{ + binding: 0, + resource: uniformBuffer + }] + + const uniformBindGroup = graphicsDevice.createBindGroup(bindGroupLayout, entries, 'Uniforms') + + async function frame() { + if (!graphicsDevice.isInitialized) { + return + } + + const commandRecorder = graphicsDevice.createCommandRecorder('FrameCommands') + + const passEncoder = commandRecorder.beginRenderPass() + + if (passEncoder) { + passEncoder.setPipeline(pipeline.handle) + passEncoder.setVertexBuffer(0, vertexBuffer.handle) + passEncoder.setBindGroup(0, uniformBindGroup.handle) + passEncoder.draw(3) + + commandRecorder.endRenderPass() + } + + const commandBuffer = commandRecorder.finish() + graphicsDevice.submitCommands([commandBuffer]) + } + + requestAnimationFrame(frame) + + // TODO: move to graphics device or somewhere else + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + const { width, height } = entry.contentRect + if (graphicsDevice.isInitialized && width > 0 && height > 0) { + graphicsDevice.swapChain.resize(width, height) + } + } + }) + + resizeObserver.observe(canvas) + + window.addEventListener('beforeunload', () => { + resizeObserver.disconnect() + graphicsDevice.destroy() + }) + +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', main) +} else { + main() +} + diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..be1904d --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "es2020", + "target": "es6", + "lib": ["es2022", "dom"], + "types": ["@webgpu/types"], + "checkJs": true, + "paths": { + "/*": ["./*"] + } + }, + "exclude": [ + "node_modules" + ] +} + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c5cb17f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "wgpu", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wgpu", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@webgpu/types": "^0.1.60" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.60", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.60.tgz", + "integrity": "sha512-8B/tdfRFKdrnejqmvq95ogp8tf52oZ51p3f4QD5m5Paey/qlX4Rhhy5Y8tgFMi7Ms70HzcMMw3EQjH/jdhTwlA==", + "dev": true, + "license": "BSD-3-Clause" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..285509e --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "wgpu", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@webgpu/types": "^0.1.60" + } +} diff --git a/server b/server new file mode 100755 index 0000000..aedad13 --- /dev/null +++ b/server @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +python -m http.server + diff --git a/src/core/command-recorder.js b/src/core/command-recorder.js new file mode 100644 index 0000000..e0c91ab --- /dev/null +++ b/src/core/command-recorder.js @@ -0,0 +1,81 @@ +import { LoadOp, StoreOp } from '../enum.js' +import { CommandRecorderError } from '../utils/errors.js' +import { SwapChain } from './swap-chain.js' + +export class CommandRecorder { + static _defaultClearValue = { r: 0, g: 0, b: 0, a: 1 } + + _device + _swapChain + _label + + _encoder + + /** @type {GPURenderPassEncoder | undefined} */ + _passEncoder + + /** + * @param {GPUDevice} device + * @param {SwapChain} swapChain + * @param {string} [label] + */ + constructor(device, swapChain, label) { + this._device = device + this._swapChain = swapChain + this._label = label + + this._encoder = device.createCommandEncoder({ label }) + } + + /** + * @returns {[GPURenderPassColorAttachment]} + */ + _defaultColorAttachment() { + const view = this._swapChain.getCurrentTextureView() + + return [{ + view, + clearValue: CommandRecorder._defaultClearValue, + loadOp: LoadOp.Clear, + storeOp: StoreOp.Store + }] + } + /** + * @param {GPURenderPassColorAttachment[]} [colorAttachments] + * @param {GPURenderPassDepthStencilAttachment} [depthStencilAttachment] + * @returns {GPURenderPassEncoder} + */ + beginRenderPass(colorAttachments, depthStencilAttachment) { + if (this._passEncoder) { + throw CommandRecorderError.activeRenderPass() + } + + const attachments = colorAttachments || this._defaultColorAttachment() + + const descriptor = { + label: this._label || 'RenderPass', + colorAttachments: attachments, + depthStencilAttachment + } + + this._passEncoder = this._encoder.beginRenderPass(descriptor) + + return this._passEncoder + } + + endRenderPass() { + if (!this._passEncoder) { return } + + this._passEncoder.end() + this._passEncoder = undefined + } + + finish() { + if (this._passEncoder) { + this.endRenderPass() + } + + return this._encoder.finish() + } +} + diff --git a/src/core/graphics-device.js b/src/core/graphics-device.js new file mode 100644 index 0000000..56ce122 --- /dev/null +++ b/src/core/graphics-device.js @@ -0,0 +1,464 @@ +import { SwapChain } from './swap-chain.js' +import { Buffer, UniformBuffer } from '../resources/buffer.js' +import { GraphicsDeviceError, WebGPUError, BufferError, WebGPUObjectError } from '../utils/errors.js' +import { GraphicsDeviceInitialized, GraphicsDeviceLost } from '../utils/events.js' +import { EventEmitter } from '../utils.js' +import { ShaderModule } from '../resources/shader-module.js' +import { RenderPipeline } from '../rendering/render-pipeline.js' +import { CommandRecorder } from './command-recorder.js' +import { BindGroupLayout } from '../resources/bind-group-layout.js' +import { BindGroup } from '../resources/bind-group.js' +import { Texture } from '../resources/texture.js' + +/** + * @typedef BaseBindGroupEntry + * @property {GPUIndex32} binding + * @property {BindingResource} resource + */ + +/** + * @typedef {{ + offset?: number + size?: number + * }} BufferBindingResource + * + * @typedef {{ textureView?: GPUTextureView }} TextureBindingResource + * + * @typedef {Buffer | Texture | Sampler} BindingResource + * + * @typedef { + (BufferBindingResource | TextureBindingResource) & + BaseBindGroupEntry + * } BindGroupEntry + */ + +import { Sampler } from '../resources/sampler.js' + +class GraphicsDeviceBuilder { + _canvas + + get canvas() { + return this._canvas + } + + /** @type {GPURequestAdapterOptions} */ + _adapter_options + + /** @type {GPUDeviceDescriptor} */ + _device_descriptor + + /** + * @param {HTMLCanvasElement} [canvasElement] + */ + constructor(canvasElement) { + this._canvas = canvasElement + } + + isSupported() { + return navigator.gpu + } + + /** + * @param {HTMLCanvasElement} canvasElement + */ + withCanvas(canvasElement) { + this._canvas = canvasElement + return this + } + + /** + * @param {GPURequestAdapterOptions} [options] + */ + withAdapter(options) { + if (!this.isSupported()) { + throw WebGPUError.unsupported() + } + + this._adapter_options = options + return this + } + + /** + * @param {GPUDeviceDescriptor} [options] + */ + withDevice(options) { + if (!this.isSupported()) { + throw WebGPUError.unsupported() + } + + this._device_descriptor = options + return this + } + + async build() { + return new GraphicsDevice( + this._canvas, + new DeviceHandler( + this._adapter_options, + this._device_descriptor + ) + ) + } +} + +class DeviceHandler { + /** @type {GPURequestAdapterOptions} */ + _adapter_options + + /** @type {GPUAdapter} */ + _adapter + + get adapter() { + return this._adapter + } + + /** @type {GPUDeviceDescriptor} */ + _device_descriptor + + /** @type {GPUDevice} */ + _device + + get device() { + return this._device + } + + /** + * @param {GPURequestAdapterOptions} adapterOptions + * @param {GPUDeviceDescriptor} deviceDescriptor + */ + constructor(adapterOptions, deviceDescriptor) { + this._adapter_options = adapterOptions + this._device_descriptor = deviceDescriptor + } + + async create() { + this._adapter = await navigator.gpu.requestAdapter(this._adapter_options) + + if (!this._adapter) { + throw WebGPUError.adapterUnavailable() + } + + this._device = await this._adapter.requestDevice(this._device_descriptor) + + if (!this._device) { + throw WebGPUError.deviceUnavailable() + } + } +} + +export class GraphicsDevice extends EventEmitter { + _canvas + _deviceHandler + + /** @type {SwapChain} */ + _swapChain + + /** @type {GPUQueue} */ + _queue + + _isInitialized = false + + get isInitialized() { + return this._isInitialized + } + + get adapter() { + return this._deviceHandler.adapter + } + + get device() { + return this._deviceHandler.device + } + + get queue() { + return this._queue + } + + get swapChain() { + return this._swapChain + } + + + /** + * @param {HTMLCanvasElement} canvas + * @param {DeviceHandler} deviceHandler + */ + constructor(canvas, deviceHandler) { + super() + + this._canvas = canvas + this._deviceHandler = deviceHandler + } + + /** + * @param {HTMLCanvasElement} [canvas] + */ + static build(canvas) { + return new GraphicsDeviceBuilder(canvas) + } + + async initialize() { + await this._deviceHandler.create() + + this._swapChain = new SwapChain( + this._canvas, + this._deviceHandler.device + ) + + this._swapChain.configure() + + this._queue = this.device.queue + + this._deviceHandler.device.lost.then(info => { + this._isInitialized = false + + this.emit( + GraphicsDeviceLost.EventName, + new GraphicsDeviceLost(info) + ) + }) + + this._isInitialized = true + + this.emit( + GraphicsDeviceInitialized.EventName, + new GraphicsDeviceInitialized(this) + ) + + return true + } + + /** + * @typedef {Omit} BufferDescriptor + */ + + /** + * Create a GPU buffer + * @param {BufferDescriptor} descriptor + * @param {ArrayBufferView | ArrayBuffer} [data] + * @returns {Buffer} + * + * @throws {GPUBufferError} + */ + createBuffer(descriptor, data) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + try { + const buffer = Buffer.create(this.device, descriptor) + + if (data) { + buffer.write(data) + } + + return buffer + } catch (err) { + throw BufferError.from(err) + } + } + + /** + * @param {number} size + * @param {ArrayBufferView | ArrayBuffer} [data] + * @param {string} [label] + */ + createUniformBuffer(size, data, label) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + const buffer = UniformBuffer.create(this.device, { + size, + label + }) + + if (data) { + buffer.write(data) + } + + return buffer + } + + /** + * Creates a shader module from WGSL code. + * @param {string} code + * @param {string} [label] + * @returns {ShaderModule} + */ + createShaderModule(code, label) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + try { + return ShaderModule.create(this.device, { code, label }) + } catch (err) { + throw WebGPUObjectError.from(err, ShaderModule) + } + } + + /** + * Creates a render pipeline. + * @param {GPURenderPipelineDescriptor} descriptor - Raw render pipeline descriptor. + * @returns {RenderPipeline} + */ + createRenderPipeline(descriptor) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + try { + const gpuPipeline = this.device.createRenderPipeline(descriptor) + return new RenderPipeline(gpuPipeline, descriptor.label) + } catch (err) { + throw WebGPUObjectError.from(err, RenderPipeline) + } + } + + /** + * Creates a CommandRecorder to begin recording GPU commands. + * @param {string} [label] + * @returns {CommandRecorder} + */ + createCommandRecorder(label) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + return new CommandRecorder(this.device, this._swapChain, label) + } + + /** + * @param {GPUBindGroupLayoutEntry[]} entries + * @param {string} [label] + */ + createBindGroupLayout(entries, label) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + return BindGroupLayout.create(this.device, { + label, + entries + }) + } + + /** + * @param {BindGroupLayout} layout + * @param {BindGroupEntry[]} bindings + * @param {string} [label] + */ + createBindGroup(layout, bindings, label) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + + const entries = bindings.map(def => ({ + binding: def.binding, + resource: def.resource.asBindingResource(def) + })) + + return BindGroup.create(this.device, { + layout: layout.handle, + entries, + label + }) + } + + /** + * @param {Array} layouts + * @param {string} [label] + */ + createPipelineLayout(layouts, label) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + const bindGroupLayouts = layouts.map(layout => layout.handle) + + return this.device.createPipelineLayout({ + label, + bindGroupLayouts + }) + } + + /** + * @param {GPUSamplerDescriptor} [descriptor] + */ + createSampler(descriptor) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + return Sampler.create(this.device, descriptor) + } + + /** + * @param {GPUTextureDescriptor} descriptor + */ + createTexture(descriptor) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + try { + return Texture.create(this.device, descriptor) + } catch (err) { + throw WebGPUObjectError.from(err, Texture) + } + } + + /** + * @param {ImageBitmap} bitmap + * @param {object} [options] + * @param {string} [options.label] + * @param {GPUTextureFormat} [options.format='rgba8unorm'] + * @param {GPUTextureUsageFlags} [options.usage=TEXTURE_BINDING | COPY_DST | RENDER_ATTACHMENT] + * @param {boolean} [options.generateMipmaps=false] + * @param {boolean} [options.flipY=false] + */ + createTextureFromBitmap(bitmap, options) { + if (!this._isInitialized) { throw GraphicsDeviceError.uninitialized() } + + if (!bitmap) { throw new TypeError('Provided bitmap is null.') } + + const usage = (options.usage ?? (GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT)) | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + + const mipLevelCount = 1 + + /** @type {GPUTextureDescriptor} */ + const descriptor = { + label: options.label, + size: { + width: bitmap.width, + height: bitmap.height, + depthOrArrayLayers: 1 + }, + format: options.format ?? 'rgba8unorm', + usage, + dimension: '2d', + mipLevelCount, + sampleCount: 1, + } + + try { + const texture = this.device.createTexture(descriptor) + this.queue.copyExternalImageToTexture( + { source: bitmap, flipY: options.flipY ?? false }, + { texture, mipLevel: 0, origin: { x: 0, y: 0, z: 0 } }, + descriptor.size + ) + const wrapper = new Texture(this.device, texture) + return wrapper + } catch (err) { + throw WebGPUObjectError.from(err, Texture) + } + } + + /** + * Submits an array of command buffers to the GPU queue. + * @param {GPUCommandBuffer[]} commandBuffers + */ + submitCommands(commandBuffers) { + if (!this._isInitialized || !commandBuffers || commandBuffers.length === 0) return + + this.queue.submit(commandBuffers) + } + + /** + * Cleans up GPU resources. Call when the application exits. + */ + destroy() { + if (this.device) { + this.device.destroy() + } + + this._deviceHandler = null + this._queue = null + this._swapChain = null + this._isInitialized = false + } +} + diff --git a/src/core/swap-chain.js b/src/core/swap-chain.js new file mode 100644 index 0000000..a3e3e85 --- /dev/null +++ b/src/core/swap-chain.js @@ -0,0 +1,99 @@ +import { WebGPUError } from '../utils/errors.js' + + +/** @import { PositiveInteger } from '../utils.js' */ + +/** + * @typedef {Omit} SwapChainConfiguration + */ +export class SwapChain { + _canvas + _device + _context + _format + _width + _height + + /** @type {SwapChainConfiguration} */ + _configuration + + get context() { + return this._context + } + + get format() { + return this._format + } + + get width() { + return this._width + } + + get height() { + return this._height + } + + /** + * @param {HTMLCanvasElement} canvas + * @param {GPUDevice} device + * @param {GPUCanvasContext} [context] + * + * @throws {WebGPUError} + * Throws an error if unable to request a WebGPU context + */ + constructor(canvas, device, context) { + this._canvas = canvas + this._device = device + + this._context = context || canvas.getContext('webgpu') + + if (!this._context) { + throw WebGPUError.contextFailure() + } + + this._format = navigator.gpu.getPreferredCanvasFormat() + this._width = canvas.width + this._height = canvas.height + } + + /** + * @param {SwapChainConfiguration} [configuration] + */ + configure(configuration) { + if (configuration) { + this._configuration = configuration + } + + this._width = this._canvas.width + this._height = this._canvas.height + + this._context.configure({ + device: this._device, + format: this._format, + alphaMode: 'opaque', + ...this._configuration + }) + } + + getCurrentTextureView() { + return this._context.getCurrentTexture().createView() + } + + /** + * @template {number} const T + * @param {PositiveInteger} width + * @param {PositiveInteger} height + */ + resize(width, height) { + if (width <= 0 || height <= 0) { + return + } + + if (this._width !== width || this._height !== height) { + this._canvas.width = width + this._canvas.height = height + this.configure() + } + } +} + diff --git a/src/enum.js b/src/enum.js new file mode 100644 index 0000000..5dad3a0 --- /dev/null +++ b/src/enum.js @@ -0,0 +1,356 @@ +import { Enum } from './utils.js' + + +export const AddressMode = Enum( + 'clamp-to-edge', + 'repeat', + 'mirror-repeat' +) + +export const AutoLayoutMode = Enum('auto') + +export const BlendFactor = Enum( + 'zero', + 'one', + 'src', + 'one-minus-src', + 'src-alpha', + 'one-minus-src-alpha', + 'dst', + 'one-minus-dst', + 'dst-alpha', + 'one-minus-dst-alpha', + 'src-alpha-saturated', + 'constant', + 'one-minus-constant', + 'src1', + 'one-minus-src1', + 'src1-alpha', + 'one-minus-src1-alpha' +) + + +export const BlendOperation = Enum( + 'add', + 'subtract', + 'reverse-subtract', + 'min', + 'max' +) + +export const BufferBindingType = Enum( + 'uniform', + 'storage', + 'read-only-storage' +) + +export const BufferMapState = Enum( + 'unmapped', + 'pending', + 'mapped' +) + +export const CanvasAlphaMode = Enum( + 'opaque', + 'premultiplied' +) + +export const CanvasToneMappingMode = Enum( + 'standard', + 'extended' +) + +export const CompareFunction = Enum( + 'never', + 'less', + 'equal', + 'less-equal', + 'greater', + 'not-equal', + 'greater-equal', + 'always' +) + +export const CompilationMessageType = Enum( + 'error', + 'warning', + 'info' +) + +export const CullMode = Enum( + 'none', + 'front', + 'back' +) + +export const DeviceLostReason = Enum( + 'unknown', + 'destroyed' +) + +export const ErrorFilter = Enum( + 'validation', + 'out-of-memory', + 'internal' +) + +export const FeatureName = Enum( + 'depth-clip-control', + 'depth32float-stencil8', + 'texture-compression-bc', + 'texture-compression-bc-sliced-3d', + 'texture-compression-etc2', + 'texture-compression-astc', + 'texture-compression-astc-sliced-3d', + 'timestamp-query', + 'indirect-first-instance', + 'shader-f16', + 'rg11b10ufloat-renderable', + 'bgra8unorm-storage', + 'float32-filterable', + 'float32-blendable', + 'clip-distances', + 'dual-source-blending' +) + +export const FilterMode = Enum( + 'nearest', + 'linear' +) + +export const FrontFace = Enum( + 'ccw', + 'cw' +) + +export const IndexFormat = Enum( + 'uint16', + 'uint32' +) + +export const LoadOp = Enum( + 'load', + 'clear' +) + +export const MipmapFilterMode = Enum( + 'nearest', + 'linear' +) + +export const PipelineErrorReason = Enum( + 'validation', + 'internal' +) + +export const PowerPreference = Enum( + 'low-power', + 'high-performance' +) + +export const PrimitiveTopology = Enum( + 'point-list', + 'line-list', + 'line-strip', + 'triangle-list', + 'triangle-strip' +) + +export const QueryType = Enum( + 'occlusion', + 'timestamp' +) + +export const SamplerBindingType = Enum( + 'filtering', + 'non-filtering', + 'comparison' +) + +export const StencilOperation = Enum( + 'keep', + 'zero', + 'replace', + 'invert', + 'increment-clamp', + 'decrement-clamp', + 'increment-wrap', + 'decrement-wrap' +) + +export const StorageTextureAccess = Enum( + 'write-only', + 'read-only', + 'read-write' +) + +export const StoreOp = Enum( + 'store', + 'discard' +) + +export const TextureAspect = Enum( + 'all', + 'stencil-only', + 'depth-only' +) + +export const TextureDimension = Enum( + '1d', + '2d', + '3d' +) + +export const TextureFormat = Enum( + 'r8unorm', + 'r8snorm', + 'r8uint', + 'r8sint', + 'r16uint', + 'r16sint', + 'r16float', + 'rg8unorm', + 'rg8snorm', + 'rg8uint', + 'rg8sint', + 'r32uint', + 'r32sint', + 'r32float', + 'rg16uint', + 'rg16sint', + 'rg16float', + 'rgba8unorm', + 'rgba8unorm-srgb', + 'rgba8snorm', + 'rgba8uint', + 'rgba8sint', + 'bgra8unorm', + 'bgra8unorm-srgb', + 'rgb9e5ufloat', + 'rgb10a2uint', + 'rgb10a2unorm', + 'rg11b10ufloat', + 'rg32uint', + 'rg32sint', + 'rg32float', + 'rgba16uint', + 'rgba16sint', + 'rgba16float', + 'rgba32uint', + 'rgba32sint', + 'rgba32float', + 'stencil8', + 'depth16unorm', + 'depth24plus', + 'depth24plus-stencil8', + 'depth32float', + 'depth32float-stencil8', + 'bc1-rgba-unorm', + 'bc1-rgba-unorm-srgb', + 'bc2-rgba-unorm', + 'bc2-rgba-unorm-srgb', + 'bc3-rgba-unorm', + 'bc3-rgba-unorm-srgb', + 'bc4-r-unorm', + 'bc4-r-snorm', + 'bc5-rg-unorm', + 'bc5-rg-snorm', + 'bc6h-rgb-ufloat', + 'bc6h-rgb-float', + 'bc7-rgba-unorm', + 'bc7-rgba-unorm-srgb', + 'etc2-rgb8unorm', + 'etc2-rgb8unorm-srgb', + 'etc2-rgb8a1unorm', + 'etc2-rgb8a1unorm-srgb', + 'etc2-rgba8unorm', + 'etc2-rgba8unorm-srgb', + 'eac-r11unorm', + 'eac-r11snorm', + 'eac-rg11unorm', + 'eac-rg11snorm', + 'astc-4x4-unorm', + 'astc-4x4-unorm-srgb', + 'astc-5x4-unorm', + 'astc-5x4-unorm-srgb', + 'astc-5x5-unorm', + 'astc-5x5-unorm-srgb', + 'astc-6x5-unorm', + 'astc-6x5-unorm-srgb', + 'astc-6x6-unorm', + 'astc-6x6-unorm-srgb', + 'astc-8x5-unorm', + 'astc-8x5-unorm-srgb', + 'astc-8x6-unorm', + 'astc-8x6-unorm-srgb', + 'astc-8x8-unorm', + 'astc-8x8-unorm-srgb', + 'astc-10x5-unorm', + 'astc-10x5-unorm-srgb', + 'astc-10x6-unorm', + 'astc-10x6-unorm-srgb', + 'astc-10x8-unorm', + 'astc-10x8-unorm-srgb', + 'astc-10x10-unorm', + 'astc-10x10-unorm-srgb', + 'astc-12x10-unorm', + 'astc-12x10-unorm-srgb', + 'astc-12x12-unorm', + 'astc-12x12-unorm-srgb' +) + +export const TextureSampleType = Enum( + 'float', + 'unfilterable-float', + 'depth', + 'sint', + 'uint' +) + +export const TextureViewDimension = Enum( + '1d', + '2d', + '2d-array', + 'cube', + 'cube-array', + '3d' +) + +export const VertexFormat = Enum( + 'uint8x2', + 'uint8x4', + 'sint8x2', + 'sint8x4', + 'unorm8x2', + 'unorm8x4', + 'snorm8x2', + 'snorm8x4', + 'uint16x2', + 'uint16x4', + 'sint16x2', + 'sint16x4', + 'unorm16x2', + 'unorm16x4', + 'snorm16x2', + 'snorm16x4', + 'float16x2', + 'float16x4', + 'float32', + 'float32x2', + 'float32x3', + 'float32x4', + 'uint32', + 'uint32x2', + 'uint32x3', + 'uint32x4', + 'sint32', + 'sint32x2', + 'sint32x3', + 'sint32x4', + 'unorm10-10-10-2' +) + +export const VertexStepMode = Enum( + 'vertex', + 'instance' +) diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e69de29 diff --git a/src/rendering/render-pipeline.js b/src/rendering/render-pipeline.js new file mode 100644 index 0000000..70fe0e2 --- /dev/null +++ b/src/rendering/render-pipeline.js @@ -0,0 +1,22 @@ +export class RenderPipeline { + _handle + _label + + get handle() { + return this._handle + } + + get label() { + return this._label + } + + /** + * @param {GPURenderPipeline} pipeline + * @param {string} [label] + */ + constructor(pipeline, label) { + this._handle = pipeline + this._label = label + } +} + diff --git a/src/resources/bind-group-layout.js b/src/resources/bind-group-layout.js new file mode 100644 index 0000000..20c5b0b --- /dev/null +++ b/src/resources/bind-group-layout.js @@ -0,0 +1,39 @@ +import { WebGPUObjectError } from '../utils/errors.js' + +export class BindGroupLayout { + _device + _handle + + get handle() { + return this._handle + } + + get label() { + return this._handle.label + } + + /** + * @param {GPUDevice} device + * @param {GPUBindGroupLayout} layout + */ + constructor(device, layout) { + this._device = device + this._handle = layout + } + + /** + * @param {GPUDevice} device + * @param {GPUBindGroupLayoutDescriptor} descriptor + */ + static create(device, descriptor) { + try { + return new BindGroupLayout( + device, + device.createBindGroupLayout(descriptor) + ) + } catch (err) { + throw WebGPUObjectError.from(err, BindGroupLayout) + } + } +} + diff --git a/src/resources/bind-group.js b/src/resources/bind-group.js new file mode 100644 index 0000000..b1435c6 --- /dev/null +++ b/src/resources/bind-group.js @@ -0,0 +1,39 @@ +import { WebGPUObjectError } from '../utils/errors.js' + +export class BindGroup { + _device + _handle + + get handle() { + return this._handle + } + + /** + * @param {GPUDevice} device + * @param {GPUBindGroup} bindGroup + */ + constructor(device, bindGroup) { + this._device = device + this._handle = bindGroup + } + + /** + * @param {GPUDevice} device + * @param {GPUBindGroupDescriptor} descriptor + */ + static create(device, descriptor) { + try { + return new BindGroup( + device, + device.createBindGroup(descriptor) + ) + } catch (err) { + throw WebGPUObjectError.from(err, BindGroup) + } + } + + destroy() { + this._handle = null + } +} + diff --git a/src/resources/buffer.js b/src/resources/buffer.js new file mode 100644 index 0000000..ac4fc45 --- /dev/null +++ b/src/resources/buffer.js @@ -0,0 +1,217 @@ +import { BufferError, WebGPUError } from '../utils/errors.js' + +/** @import { TypedArray, TypedArrayConstructor } from '../utils.js' */ +/** @import { BindGroupEntry, BindingResource, BufferBindingResource } from '../core/graphics-device.js' */ + +export class Buffer { + _device + _handle + + _mapped = false + + /** @type {GPUBuffer} */ + _defaultStagingBuffer + + get handle() { + return this._handle + } + + get size() { + return this._handle.size + } + + get usage() { + return this._handle.usage + } + + /** + * @param {GPUDevice} device + * @param {GPUBuffer} texture + */ + constructor(device, texture) { + this._device = device + this._handle = texture + } + + /** + * @param {GPUDevice} device + * @param {GPUBufferDescriptor} descriptor + */ + static create(device, descriptor) { + try { + return new Buffer( + device, + device.createBuffer(descriptor) + ) + } catch (err) { + throw BufferError.from(err) + } + } + + /** + * @param {number} [size] + */ + _createStagingOptions(size = this.size) { + return { + size, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST + } + } + + /** + * @param {number} [size] + */ + _getStagingBuffer(size) { + return this._device.createBuffer(this._createStagingOptions(size)) + } + + /** + * @param {ArrayBufferView | ArrayBuffer} data + * @param {number} [offset=0] + * @param {number} [dataOffset=0] + */ + write(data, offset = 0, dataOffset = 0) { + if (!(this.usage & GPUBufferUsage.COPY_DST)) { + console.warn('Buffer usage does not include COPY_DST. Buffer.write may fail.') + } + + if (!this._device?.queue) { + throw WebGPUError.deviceUnavailable() + } + + this._device.queue.writeBuffer( + this._handle, + offset, + data, + dataOffset + ) + } + + /** + * @typedef {Exclude} SmallTypedArray + * @typedef {Exclude} SmallTypedArrayConstructor + */ + + /** + * @param {SmallTypedArray | DataView | undefined} [out] + * @param {number} [byteOffset=0] + * @param {number} [byteSize] + */ + async read(out, byteOffset = 0, byteSize = -1) { + if (!this._device) { + throw WebGPUError.deviceUnavailable() + } + + if (!(this.usage & GPUBufferUsage.MAP_READ)) { + throw BufferError.invalidRead() + } + + if (byteOffset < 0) { throw new RangeError('Read byteOffset cannot be negative') } + if (byteSize < 0) { byteSize = this.size - byteOffset } + if (byteSize < 0) { throw new RangeError(`Invalid calculated byteSize (${byteSize})`) } + if (byteSize === 0) { return out ?? new ArrayBuffer(0) } + if (byteOffset + byteSize > this.size) { throw new RangeError(`Read range exceeds buffer size`) } + + + if (out != null) { + if (!ArrayBuffer.isView(out)) { throw new TypeError('"out" parameter must be a TypedArray or DataView.') } + + if (out.byteLength < byteSize) { throw new RangeError(`Provided output buffer too small`) } + } + + let result + let range + + try { + await this.handle.mapAsync(GPUMapMode.READ, byteOffset, byteSize) + range = this.handle.getMappedRange(byteOffset, byteSize) + + if (out != null) { + const SourceView = /** @type {SmallTypedArrayConstructor} */ (out.constructor) + const bytesPerElement = SourceView.BYTES_PER_ELEMENT + + if (!bytesPerElement) { + if (out instanceof DataView) { + new Uint8Array( + out.buffer, + out.byteOffset, + byteSize + ).set(new Uint8Array(range)) + } else { + throw new TypeError('"out" is not a standard TypedArray or DataView') + } + } else { + if (byteSize % bytesPerElement !== 0) { + throw new RangeError(`"byteSize" (${byteSize}) incompatible with "out" byte size (${bytesPerElement})`) + } + + const view = new SourceView(range) + const target = new SourceView( + out.buffer, + out.byteOffset, + byteSize / bytesPerElement + ) + + target.set(view) + } + + result = out + } else { + result = range.slice(0) + } + } catch (err) { + throw BufferError.from(err) + } finally { + this.handle.unmap() + } + + return result + } + + /** + * @param {BindGroupEntry} entry + */ + asBindingResource(entry) { + const offset = 'offset' in entry ? entry.offset : 0 + const size = 'size' in entry ? entry.size : this.size - offset + + if (offset + size > this.size) { + throw BufferError.outOfBounds(offset, size) + } + + return { + buffer: this._handle, + offset, + size + } + } + + destroy() { + this._handle?.destroy() + this._handle = null + } +} + + +export class UniformBuffer extends Buffer { + /** + * @param {GPUDevice} device + * @param {GPUBuffer} buffer + */ + constructor(device, buffer) { + super(device, buffer) + } + + /** + * @param {GPUDevice} device + * @param {Omit} descriptor + */ + static create(device, descriptor) { + const usage = GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + return super.create(device, { + usage, + ...descriptor + }) + } +} + diff --git a/src/resources/sampler.js b/src/resources/sampler.js new file mode 100644 index 0000000..d5ba5a1 --- /dev/null +++ b/src/resources/sampler.js @@ -0,0 +1,48 @@ +import { WebGPUObjectError } from '../utils/errors.js' + +/** @import { BindGroupEntry } from '../core/graphics-device.js' */ + +export class Sampler { + _device + _handle + + get handle() { + return this._handle + } + + get label() { + return this._handle.label + } + + /** + * @param {GPUDevice} device + * @param {GPUSampler} sampler + */ + constructor(device, sampler) { + this._device = device + this._handle = sampler + } + + /** + * @param {GPUDevice} device + * @param {GPUSamplerDescriptor} descriptor + */ + static create(device, descriptor) { + try { + return new Sampler( + device, + device.createSampler(descriptor) + ) + } catch (err) { + throw WebGPUObjectError.from(err, Sampler) + } + } + + /** + * @param {BindGroupEntry} _entry + */ + asBindingResource(_entry) { + return this._handle + } +} + diff --git a/src/resources/shader-module.js b/src/resources/shader-module.js new file mode 100644 index 0000000..c63ca16 --- /dev/null +++ b/src/resources/shader-module.js @@ -0,0 +1,35 @@ +import { WebGPUObjectError } from '../utils/errors.js' + +export class ShaderModule { + _handle + + get label() { + return this._handle.label + } + + get handle() { + return this._handle + } + + /** + * @param {GPUShaderModule} module + */ + constructor(module) { + this._handle = module + } + + /** + * @param {GPUDevice} device + * @param {GPUShaderModuleDescriptor} descriptor + */ + static create(device, descriptor) { + try { + return new ShaderModule( + device.createShaderModule(descriptor) + ) + } catch (err) { + throw WebGPUObjectError.from(err, ShaderModule) + } + } +} + diff --git a/src/resources/texture.js b/src/resources/texture.js new file mode 100644 index 0000000..3dfefec --- /dev/null +++ b/src/resources/texture.js @@ -0,0 +1,111 @@ +import { WebGPUObjectError } from '../utils/errors.js' + +/** @import { BindGroupEntry } from '../core/graphics-device.js' */ + +export class Texture { + _device + _handle + + /** @type {GPUTextureView | undefined} */ + _defaultView + + get handle() { + return this._handle + } + + get width() { + return this._handle.width + } + + get height() { + return this._handle.height + } + + get format() { + return this._handle.format + } + + get depthOrArrayLayers() { + return this._handle.depthOrArrayLayers + } + + get usage() { + return this._handle.usage + } + + get dimension() { + return this._handle.dimension + } + + get mipLevelCount() { + return this._handle.mipLevelCount + } + + get label() { + return this._handle.label + } + + /** + * @param {GPUDevice} device + * @param {GPUTexture} texture + */ + constructor(device, texture) { + this._device = device + this._handle = texture + } + + /** + * @param {GPUDevice} device + * @param {GPUTextureDescriptor} descriptor + */ + static create(device, descriptor) { + try { + return new Texture( + device, + device.createTexture(descriptor) + ) + } catch (err) { + throw WebGPUObjectError.from(err, Texture) + } + } + + /** + * @param {GPUTextureViewDescriptor} [descriptor] + * @throws {TextureError} + */ + createDefaultView(descriptor) { + if (!descriptor && this._defaultView) { + return this._defaultView + } + + try { + const view = this._handle.createView(descriptor) + + if (!descriptor) { + this._defaultView = view + } + + return view + } catch (err) { + throw WebGPUObjectError.from(err, Texture) + } + } + + /** + * @param {BindGroupEntry} entry + */ + asBindingResource(entry) { + return 'textureView' in entry ? entry.textureView : this.getView() + } + + getView() { + return this.createDefaultView() + } + + destroy() { + this._handle?.destroy() + this._handle = undefined + this._defaultView = undefined + } +} + diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..d26029f --- /dev/null +++ b/src/utils.js @@ -0,0 +1,171 @@ +/** + * @template T + * @typedef {new (...args: any[]) => T} Newable + */ + +/** + * @typedef { + Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array + } TypedArray + */ + +/** + * @typedef { + Int8ArrayConstructor + | Uint8ArrayConstructor + | Uint8ClampedArrayConstructor + | Int16ArrayConstructor + | Uint16ArrayConstructor + | Int32ArrayConstructor + | Uint32ArrayConstructor + | Float32ArrayConstructor + | Float64ArrayConstructor + | BigInt64ArrayConstructor + | BigUint64ArrayConstructor + } TypedArrayConstructor + */ + +/** + * @template {number} T + * @typedef {`${T}` extends `-${string}` | '0' | `${string}.${string}` ? never : T } PositiveInteger + */ + +/** + * @template {ReadonlyArray} T + * @typedef {T extends readonly [] ? [] : T extends readonly [infer Head extends string, ... infer Tail extends ReadonlyArray] ? [Capitalize, ...CapitalizeAll] : string[]} CapitalizeAll + */ + +/** + * @template {ReadonlyArray} T + * @template {string} [Sep=''] + * @typedef {T extends readonly [] ? '' : T extends readonly [infer Head] ? Head : T extends readonly [infer Head extends string, ...infer Tail extends ReadonlyArray] ? `${Head}${Sep}${Join}` : string} Join + */ + +/** + * @template {string} S + * @template {string} D + * @typedef {D extends '' ? [S] : S extends `${infer Head}${D}${infer Tail}` ? [Head, ...Split] : [S]} Split + */ + +/* + * @typedef {'-' | '_' | ' '} WordSeparator + */ + +/** + * @template {string} S + * @template {string} D + * @typedef {S extends `${infer Left}${D}${infer Right}` ? `${PascalCaseFromDelimiter}${Capitalize>}` : S} PascalCaseFromDelimiter + */ + +/** + * @template {string} S + * @typedef {Capitalize, '-'>, '_'>, ' '>>} PascalCaseString + */ + +/** + * @template {string | ReadonlyArray} T + * @typedef {T extends String ? PascalCaseString : T extends ReadonlyArray ? Join, ''> : T} PascalCase + */ + +/** + * @template {string} T + * @param {T} s + * @returns {Uppercase} + */ +export const uppercase = s => /** @type {Uppercase} */(s.toUpperCase()) + +/** + * @template {string} T + * @param {T} s + * @returns {Lowercase} + */ +export const lowercase = s => /** @type {Lowercase} */(s.toLowerCase()) + +/** + * @template {string} const T + * @param {T} s + * @returns {Split} + */ +export const fromKebab = s => /** @type {Split} */(s.split('-')) + +/** + * @template {string} const T + * @param {T} xs + * @returns {PascalCase} + */ +export const toPascal = xs => /** @type {PascalCase} */(uppercase(xs[0]) + xs.slice(1)) + +/** + * @template {string[]} const T + * @param {T} xs + * @returns {PascalCase} + */ +const pascal = xs => /** @type {PascalCase} */(xs.map(toPascal).join('')) + +/** + * @template {string} const T + * @param {...T} values + * @returns {Readonly<{ [K in T as PascalCase]: K }>} + */ +export const Enum = (...values) => /** @type {Readonly<{ [K in T as PascalCase]: K }>} */(Object.freeze( + values.reduce((acc, x) => { + const key = pascal(fromKebab(x)).toString() + acc[key] = x + return acc + }, {}) +)) + + +/** @typedef {(...args: any) => void} Listener */ +export class EventEmitter { + _listeners = {} + + /** + * @param {PropertyKey} event + * @param {Listener} callback + */ + on(event, callback) { + this._listeners[event] = this._listeners[event] || [] + this._listeners[event].push(callback) + } + + /** + * @param {PropertyKey} event + * @param {...any} args + */ + emit(event, ...args) { + const listeners = this._listeners[event] + + if (listeners) { + listeners.forEach( + /** @param {Listener} cb */ + cb => cb(...args) + ) + } + } + + /** + * @param {PropertyKey} event + * @param {Listener} callback + */ + off(event, callback) { + const listeners = this._listeners[event] + + if (listeners) { + this._listeners[event] = listeners.filter( + /** @param {Listener} cb */ + cb => cb !== callback + ) + } + } +} diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 0000000..811fb11 --- /dev/null +++ b/src/utils/errors.js @@ -0,0 +1,80 @@ +/** @import { Newable } from '../utils.js' */ + +export class WebGPUError extends Error { + /** + * @param {string} message + * @param {ErrorOptions} [options] + */ + constructor(message, options) { + super(`WebGPUError: ${message}`, options) + } + + static unsupported() { + return new WebGPUError('WebGPU is not supported on this browser') + } + + static contextFailure() { + return new WebGPUError('Failed to request WebGPU context') + } + + static adapterUnavailable() { + return new WebGPUError('Failed to request GPU adapter') + } + + static deviceUnavailable() { + return new WebGPUError('Failed to request GPU device') + } +} + +export class GraphicsDeviceError extends Error { + static uninitialized() { + return new GraphicsDeviceError('GraphicsDeviceError: GraphicsDevice not initialized') + } +} + +export class CommandRecorderError extends Error { + static activeRenderPass() { + return new CommandRecorderError('CommandRecorderError: can\'t begin new render pass while another is active. call CommandRecorder.end()') + } +} + +export class WebGPUObjectError extends Error { + /** + * @template T + * @param {Error} cause + * @param {string | Newable} [type] + */ + static from(cause, type) { + const name = typeof type === 'string' ? type : type.name + return new WebGPUObjectError(`could not create ${name}`, { cause }) + } +} + +export class BufferError extends WebGPUObjectError { + /** + * @param {string} message + * @param {ErrorOptions} [options] + */ + constructor(message, options) { + super(`BufferError: ${message}`, options) + } + + /** + * @param {Error} cause + */ + static from(cause) { + return new BufferError('could not create buffer', { cause }) + } + + static invalidRead() { + return new BufferError('cannot read a buffer without MAP_READ usage') + } + + /** + * @param {number} offset + * @param {number} size + */ + static outOfBounds(offset, size) { + return new BufferError(`buffer offset/size (${offset}/${size}) exceeds buffer dimensions`) + } +} diff --git a/src/utils/events.js b/src/utils/events.js new file mode 100644 index 0000000..538d45c --- /dev/null +++ b/src/utils/events.js @@ -0,0 +1,27 @@ +import { GraphicsDevice } from '../core/graphics-device.js' + +export class GraphicsDeviceInitialized { + static EventName = 'graphics-device:initialized' + graphicsDevice + + /** + * @param {GraphicsDevice} graphicsDevice + */ + constructor(graphicsDevice) { + this.graphicsDevice = graphicsDevice + } +} + +export class GraphicsDeviceLost { + static EventName = 'graphics-device:device-lost' + info + + /** + * @param {GPUDeviceLostInfo} info + */ + constructor(info) { + this.info = info + } +} + +