initial commit

This commit is contained in:
Rowan 2025-04-15 05:34:18 -05:00
commit 2c5ac481a4
23 changed files with 2052 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/

3
client Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env sh
servo --pref dom_webgpu_enabled=true http://localhost:8000

30
index.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGPU</title>
<style>
body {
margin: 0;
background-color: #222;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
canvas {
border: 1px solid #555;
background-color: black;
}
</style>
</head>
<body>
<canvas id="webgpu-canvas"></canvas>
<script type="module" src="./index.js"></script>
</body>
</html>

169
index.js Normal file
View file

@ -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<f32>) -> @builtin(position) vec4<f32> {
return vec4<f32>(in_pos, 1.0);
}
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(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<import('./src/core/graphics-device.js').BindGroupEntry>} */
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()
}

16
jsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "es2020",
"target": "es6",
"lib": ["es2022", "dom"],
"types": ["@webgpu/types"],
"checkJs": true,
"paths": {
"/*": ["./*"]
}
},
"exclude": [
"node_modules"
]
}

23
package-lock.json generated Normal file
View file

@ -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"
}
}
}

16
package.json Normal file
View file

@ -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"
}
}

4
server Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
python -m http.server

View file

@ -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()
}
}

464
src/core/graphics-device.js Normal file
View file

@ -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<GPUBufferDescriptor, 'mappedAtCreation'>} 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<BindGroupLayout>} 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
}
}

99
src/core/swap-chain.js Normal file
View file

@ -0,0 +1,99 @@
import { WebGPUError } from '../utils/errors.js'
/** @import { PositiveInteger } from '../utils.js' */
/**
* @typedef {Omit<GPUCanvasConfiguration, 'device' | 'format'>} 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<T>} width
* @param {PositiveInteger<T>} 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()
}
}
}

356
src/enum.js Normal file
View file

@ -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'
)

0
src/index.js Normal file
View file

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}

217
src/resources/buffer.js Normal file
View file

@ -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<TypedArray, BigInt64Array | BigUint64Array>} SmallTypedArray
* @typedef {Exclude<TypedArrayConstructor, BigInt64ArrayConstructor | BigUint64ArrayConstructor>} 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<GPUBufferDescriptor, 'usage'>} descriptor
*/
static create(device, descriptor) {
const usage = GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
return super.create(device, {
usage,
...descriptor
})
}
}

48
src/resources/sampler.js Normal file
View file

@ -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
}
}

View file

@ -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)
}
}
}

111
src/resources/texture.js Normal file
View file

@ -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
}
}

171
src/utils.js Normal file
View file

@ -0,0 +1,171 @@
/**
* @template T
* @typedef {new (...args: any[]) => T} Newable<T>
*/
/**
* @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<string>} T
* @typedef {T extends readonly [] ? [] : T extends readonly [infer Head extends string, ... infer Tail extends ReadonlyArray<string>] ? [Capitalize<Head>, ...CapitalizeAll<Tail>] : string[]} CapitalizeAll
*/
/**
* @template {ReadonlyArray<string>} 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<string>] ? `${Head}${Sep}${Join<Tail, Sep>}` : string} Join
*/
/**
* @template {string} S
* @template {string} D
* @typedef {D extends '' ? [S] : S extends `${infer Head}${D}${infer Tail}` ? [Head, ...Split<Tail, D>] : [S]} Split
*/
/*
* @typedef {'-' | '_' | ' '} WordSeparator
*/
/**
* @template {string} S
* @template {string} D
* @typedef {S extends `${infer Left}${D}${infer Right}` ? `${PascalCaseFromDelimiter<Left, D>}${Capitalize<PascalCaseFromDelimiter<Right, D>>}` : S} PascalCaseFromDelimiter
*/
/**
* @template {string} S
* @typedef {Capitalize<PascalCaseFromDelimiter<PascalCaseFromDelimiter<PascalCaseFromDelimiter<Lowercase<S>, '-'>, '_'>, ' '>>} PascalCaseString
*/
/**
* @template {string | ReadonlyArray<string>} T
* @typedef {T extends String ? PascalCaseString<T> : T extends ReadonlyArray<string> ? Join<CapitalizeAll<T>, ''> : T} PascalCase
*/
/**
* @template {string} T
* @param {T} s
* @returns {Uppercase<T>}
*/
export const uppercase = s => /** @type {Uppercase<T>} */(s.toUpperCase())
/**
* @template {string} T
* @param {T} s
* @returns {Lowercase<T>}
*/
export const lowercase = s => /** @type {Lowercase<T>} */(s.toLowerCase())
/**
* @template {string} const T
* @param {T} s
* @returns {Split<T, '-'>}
*/
export const fromKebab = s => /** @type {Split<T, '-'>} */(s.split('-'))
/**
* @template {string} const T
* @param {T} xs
* @returns {PascalCase<T>}
*/
export const toPascal = xs => /** @type {PascalCase<T>} */(uppercase(xs[0]) + xs.slice(1))
/**
* @template {string[]} const T
* @param {T} xs
* @returns {PascalCase<T>}
*/
const pascal = xs => /** @type {PascalCase<T>} */(xs.map(toPascal).join(''))
/**
* @template {string} const T
* @param {...T} values
* @returns {Readonly<{ [K in T as PascalCase<K>]: K }>}
*/
export const Enum = (...values) => /** @type {Readonly<{ [K in T as PascalCase<K>]: 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
)
}
}
}

80
src/utils/errors.js Normal file
View file

@ -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<T>} [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`)
}
}

27
src/utils/events.js Normal file
View file

@ -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
}
}