/// import { Vector3 } from './Vector3' import type { Camera } from './Camera' import { Mesh, type Mode } from './Mesh' import type { Object3D } from './Object3D' import { type RenderTarget } from './RenderTarget' import { Compiled, min } from './_utils' import type { Attribute, AttributeData, Geometry } from './Geometry' import type { Material, Side, Uniform } from './Material' import { Texture } from './Texture' import type { Wrapping, Sampler } from './Sampler' const GPU_CULL_SIDES: Record = { front: 'back', back: 'front', both: 'none', } as const const GPU_DRAW_MODES: Record = { points: 'point-list', lines: 'line-list', triangles: 'triangle-list', } as const const GPU_WRAPPING: Record = { clamp: 'clamp-to-edge', repeat: 'repeat', mirror: 'mirror-repeat', } const GPU_BUFFER_USAGE_COPY_DST = 0x8 const GPU_BUFFER_USAGE_INDEX = 0x10 const GPU_BUFFER_USAGE_VERTEX = 0x20 const GPU_BUFFER_USAGE_UNIFORM = 0x40 const GPU_BUFFER_USAGE_STORAGE = 0x80 const GPU_TEXTURE_USAGE_COPY_SRC = 0x1 const GPU_TEXTURE_USAGE_COPY_DST = 0x2 const GPU_TEXTURE_USAGE_TEXTURE_BINDING = 0x4 const GPU_TEXTURE_USAGE_RENDER_ATTACHMENT = 0x10 const GPU_COLOR_WRITE_ALL = 0xf // Pad to 16 byte chunks of 2, 4 (std140 layout) const pad2 = (n: number) => n + (n % 2) const pad4 = (n: number) => n + ((4 - (n % 4)) % 4) /** * Packs uniforms into a std140 compliant array buffer. */ function std140(uniforms: Uniform[], buffer?: Float32Array): Float32Array { const values = uniforms as Exclude[] // Init buffer if (!buffer) { const length = pad4( values.reduce( (n: number, u) => n + (typeof u === 'number' ? 1 : u.length <= 2 ? pad2(u.length) : pad4(u.length)), 0, ), ) buffer = new Float32Array(length) } // Pack buffer let offset = 0 for (const value of values) { if (typeof value === 'number') { buffer[offset] = value offset += 1 // leave empty space to stack primitives } else { const pad = value.length <= 2 ? pad2 : pad4 offset = pad(offset) // fill in empty space buffer.set(value, offset) offset += pad(value.length) } } return buffer } /** * Returns a list of used uniforms from shader uniform structs. */ function parseUniforms(...shaders: string[]): string[] | undefined { shaders = shaders.filter(Boolean) // Filter to most complete definition if (shaders.length > 1) { const definitions = shaders.map((shader) => parseUniforms(shader)) return definitions.filter(Boolean).sort((a: any, b: any) => b.length - a.length)[0] } // Remove comments for parsing const shader = shaders[0].replace(/\/\*(?:[^*]|\**[^*/])*\*+\/|\/\/.*/g, '') // Detect and parse shader layout const selector = shader.match(/var\s*<\s*uniform\s*>[^;]+(?:\s|:)(\w+);/)?.[1] const layout = shader.match(new RegExp(`${selector}[^\\{]+\\{([^\\}]+)\\}`))?.[1] // Parse definitions if (layout) return Array.from(layout.match(/\w+(?=[;:])/g)!) } /** * Matches against WGSL storage bindings. */ const STORAGE_REGEX = /var\s*<\s*storage[^>]+>\s*(\w+)/g /** * Matches against WGSL workgroup attributes. */ const WORKGROUP_REGEX = /@workgroup_size\s*\(([^)]+)\)/ const _adapter = typeof navigator !== 'undefined' && (await navigator.gpu?.requestAdapter()) const _device = await (_adapter as GPUAdapter | null)?.requestDevice() /** * {@link WebGPURenderer} constructor parameters. */ export interface WebGPURendererOptions { /** * An optional {@link HTMLCanvasElement} to draw to. */ canvas: HTMLCanvasElement /** * An optional {@link GPUCanvasContext} to draw with. */ context: GPUCanvasContext /** * An optional {@link GPUDevice} to send GPU commands to. */ device: GPUDevice /** * An optional {@link GPUTextureFormat} to create texture views with. */ format: GPUTextureFormat } /** * Constructs a WebGPU renderer object. Can be extended to draw to a canvas. */ export class WebGPURenderer { /** * Output {@link HTMLCanvasElement} to draw to. */ readonly canvas: HTMLCanvasElement /** * Internal {@link GPUDevice} to send GPU commands to. */ public device!: GPUDevice /** * Internal {@link GPUTextureFormat} to create texture views with. */ public format!: GPUTextureFormat /** * Internal {@link GPUCanvasContext} to draw with. */ public context!: GPUCanvasContext /** * Whether to clear the drawing buffer between renders. Default is `true`. */ public autoClear = true /** * Number of samples to use for MSAA rendering. Default is `4` */ public samples = 4 private _buffers = new Compiled() private _geometry = new Compiled() private _UBOs = new Compiled() private _pipelines = new Compiled() private _textures = new Compiled() private _samplers = new Compiled() private _FBOs = new Compiled< RenderTarget, { views: GPUTextureView[]; depthTexture: GPUTexture; depthTextureView: GPUTextureView } >() private _msaaTexture!: GPUTexture private _msaaTextureView!: GPUTextureView private _depthTexture!: GPUTexture private _depthTextureView!: GPUTextureView private _commandEncoder!: GPUCommandEncoder private _passEncoder!: GPURenderPassEncoder | GPUComputePassEncoder private _renderTarget: RenderTarget | null = null private _v = new Vector3() constructor({ canvas, context, format, device }: Partial = {}) { this.canvas = canvas ?? document.createElement('canvas') this.context = context ?? this.canvas.getContext('webgpu')! this.format = format ?? navigator.gpu.getPreferredCanvasFormat() this.device = device ?? _device! this._resizeSwapchain() } private _resizeSwapchain(): void { this.context.configure({ device: this.device, format: this.format, alphaMode: 'premultiplied', }) const size = [this.canvas.width, this.canvas.height, 1] const usage = GPU_TEXTURE_USAGE_RENDER_ATTACHMENT const sampleCount = this.samples if (this._msaaTexture) this._msaaTexture.destroy() this._msaaTexture = this.device.createTexture({ format: this.format, size, usage, sampleCount, }) this._msaaTextureView = this._msaaTexture.createView() if (this._depthTexture) this._depthTexture.destroy() this._depthTexture = this.device.createTexture({ format: 'depth24plus-stencil8', size, usage, sampleCount, }) this._depthTextureView = this._depthTexture.createView() } /** * Sets the canvas size. */ setSize(width: number, height: number): void { this.canvas.width = width this.canvas.height = height this._resizeSwapchain() } /** * Sets the current {@link RenderTarget} to render into. */ setRenderTarget(renderTarget: RenderTarget | null): void { this._renderTarget = renderTarget } private _createBuffer(data: AttributeData, usage: GPUBufferUsageFlags): GPUBuffer { const buffer = this.device.createBuffer({ size: data.byteLength, usage: usage | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_STORAGE, mappedAtCreation: true, }) new (data.constructor as Float32ArrayConstructor)(buffer.getMappedRange()).set(data) buffer.unmap() return buffer } private _writeBuffer( buffer: GPUBuffer, data: AttributeData, byteLength = data.byteLength, srcByteOffset = 0, dstByteOffset = data.byteOffset, ): void { const size = data.BYTES_PER_ELEMENT this.device.queue.writeBuffer(buffer, dstByteOffset, data, srcByteOffset / size, byteLength / size) } /** * Updates sampler parameters. */ private _updateSampler(sampler: Sampler): GPUSampler { let target = this._samplers.get(sampler) if (!target || sampler.needsUpdate) { target = this.device.createSampler({ addressModeU: GPU_WRAPPING[sampler.wrapS] as GPUAddressMode, addressModeV: GPU_WRAPPING[sampler.wrapT] as GPUAddressMode, magFilter: sampler.magFilter as GPUFilterMode, minFilter: sampler.minFilter as GPUFilterMode, maxAnisotropy: sampler.anisotropy, }) this._samplers.set(sampler, target) } return target } /** * Updates a texture with an optional `width` and `height`. */ private _updateTexture( texture: Texture, width = texture.image?.width ?? 0, height = texture.image?.height ?? 0, ): GPUTexture | GPUExternalTexture { let target = this._textures.get(texture) if (!target || texture.needsUpdate) { texture.dispose() if (texture.image instanceof HTMLVideoElement) { target = this.device.importExternalTexture({ source: texture.image }) } else { target = this.device.createTexture({ format: (texture.format as GPUTextureFormat | undefined) ?? this.format, dimension: '2d', size: [width, height, 1], usage: GPU_TEXTURE_USAGE_COPY_DST | GPU_TEXTURE_USAGE_TEXTURE_BINDING | GPU_TEXTURE_USAGE_RENDER_ATTACHMENT | GPU_TEXTURE_USAGE_COPY_SRC, }) if (texture.image) { this.device.queue.copyExternalImageToTexture({ source: texture.image }, { texture: target }, [width, height]) } texture.needsUpdate = false } this._textures.set(texture, target, () => (target as any).destroy?.()) } this._updateSampler(texture.sampler) return target } /** * Compiles a mesh or program and sets initial uniforms. */ compile(mesh: Mesh, camera?: Camera): void { mesh.material.uniforms.modelMatrix = mesh.matrix if (camera) { mesh.material.uniforms.projectionMatrix = camera.projectionMatrix mesh.material.uniforms.viewMatrix = camera.viewMatrix mesh.material.uniforms.normalMatrix = mesh.normalMatrix mesh.material.uniforms.modelViewMatrix = mesh.modelViewMatrix mesh.modelViewMatrix.copy(camera.viewMatrix).multiply(mesh.matrix) mesh.normalMatrix.normal(mesh.modelViewMatrix) } const buffers: GPUVertexBufferLayout[] = [] let shaderLocation = 0 for (const key in mesh.geometry.attributes) { const attribute = mesh.geometry.attributes[key] if (key === 'index') continue const formatType = attribute.data instanceof Float32Array ? 'float' : 'uint' const formatBits = attribute.data.BYTES_PER_ELEMENT * 8 const formatName = formatType + formatBits buffers.push({ arrayStride: attribute.size * attribute.data.BYTES_PER_ELEMENT * (attribute.divisor || 1), stepMode: attribute.divisor ? 'instance' : 'vertex', attributes: [ { shaderLocation: shaderLocation++, offset: 0, format: `${formatName}x${Math.min(attribute.size, 4)}`, }, ] as Iterable, }) } let pipeline = this._pipelines.get(mesh) if (mesh.material.compute) { if (!pipeline) { pipeline = this.device.createComputePipeline({ compute: { module: this.device.createShaderModule({ code: mesh.material.compute! }), entryPoint: 'main', }, layout: 'auto', }) } } else { const transparent = mesh.material.transparent const cullMode = GPU_CULL_SIDES[mesh.material.side] as GPUCullMode const topology = GPU_DRAW_MODES[mesh.mode] as GPUPrimitiveTopology const depthWriteEnabled = mesh.material.depthWrite const depthCompare = (mesh.material.depthTest ? 'less' : 'always') as GPUCompareFunction const blending = mesh.material.blending const colorAttachments = this._renderTarget?.count ?? 1 const samples = this.samples const pipelineCacheKey = JSON.stringify([ transparent, cullMode, topology, depthWriteEnabled, depthCompare, buffers, blending, colorAttachments, samples, ]) if (!pipeline || pipeline.label !== pipelineCacheKey) { pipeline = this.device.createRenderPipeline({ label: pipelineCacheKey, vertex: { module: this.device.createShaderModule({ code: mesh.material.vertex }), entryPoint: 'main', buffers, }, fragment: { module: this.device.createShaderModule({ code: mesh.material.fragment }), entryPoint: 'main', targets: Array(colorAttachments).fill({ format: this.format, blend: mesh.material.blending, writeMask: GPU_COLOR_WRITE_ALL, }), }, primitive: { frontFace: 'ccw', cullMode, topology, }, depthStencil: { depthWriteEnabled, depthCompare, format: 'depth24plus-stencil8', }, multisample: { count: samples }, layout: 'auto', }) } this._pipelines.set(mesh, pipeline) } this._passEncoder.setPipeline(pipeline as any) let slot = 0 for (const key in mesh.geometry.attributes) { const attribute = mesh.geometry.attributes[key] const isIndex = key === 'index' let buffer = this._buffers.get(attribute) if (!buffer) { buffer = this._createBuffer(attribute.data, isIndex ? GPU_BUFFER_USAGE_INDEX : GPU_BUFFER_USAGE_VERTEX) this._buffers.set(attribute, buffer) } if (attribute.needsUpdate) this._writeBuffer(buffer, attribute.data) attribute.needsUpdate = false if (this._passEncoder instanceof GPURenderPassEncoder) { if (isIndex) this._passEncoder.setIndexBuffer(buffer, `uint${attribute.data.BYTES_PER_ELEMENT * 8}` as GPUIndexFormat) else this._passEncoder.setVertexBuffer(slot++, buffer) } } if (!this._geometry.get(mesh.geometry)) { this._geometry.set(mesh.geometry, true, () => { for (const key in mesh.geometry.attributes) { const attribute = mesh.geometry.attributes[key] this._buffers.get(attribute)?.destroy() this._buffers.delete(attribute) attribute.needsUpdate = true } }) } let binding = 0 const entries: GPUBindGroupEntry[] = [] const storage = mesh.material.compute?.matchAll(STORAGE_REGEX) if (storage) { for (const [, output] of storage) { const attribute = mesh.geometry.attributes[output] const buffer = this._buffers.get(attribute)! entries.push({ binding: binding++, resource: { buffer } }) } } const parsed = parseUniforms(mesh.material.vertex, mesh.material.fragment, mesh.material.compute) if (parsed) { const uniforms = parsed.map((key) => mesh.material.uniforms[key]) let UBO = this._UBOs.get(mesh.material) if (!UBO) { const data = std140(uniforms) const buffer = this._createBuffer(data, GPU_BUFFER_USAGE_UNIFORM) UBO = { data, buffer } this._UBOs.set(mesh.material, UBO, () => buffer.destroy()) } else { this._writeBuffer(UBO.buffer, std140(uniforms, UBO.data)) } entries.push({ binding: binding++, resource: UBO }) } for (const key in mesh.material.uniforms) { const value = mesh.material.uniforms[key] if (value instanceof Texture) { this._updateTexture(value) const sampler = this._samplers.get(value.sampler) if (sampler) entries.push({ binding: binding++, resource: sampler }) const target = this._textures.get(value)! entries.push({ binding: binding++, resource: target instanceof GPUTexture ? target.createView?.() : target }) } } if (entries.length) { const bindGroup = this.device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries, }) this._passEncoder.setBindGroup(0, bindGroup) } } /** * Returns a list of visible meshes. Will frustum cull and depth-sort with a camera if available. */ sort(scene: Object3D, camera?: Camera): Mesh[] { const renderList: Mesh[] = [] if (camera?.matrixAutoUpdate) { camera.projectionViewMatrix.copy(camera.projectionMatrix).multiply(camera.viewMatrix) camera.frustum.fromMatrix4(camera.projectionViewMatrix) camera.frustum.normalNDC() } scene.traverse((node) => { // Skip invisible nodes if (!node.visible) return true // Filter to meshes if (!(node instanceof Mesh)) return // Frustum cull if able if (camera && node.frustumCulled) { const inFrustum = camera.frustum.contains(node) if (!inFrustum) return true } renderList.push(node) }) return renderList.sort( (a, b) => // Push UI to front (b.material.depthTest as unknown as number) - (a.material.depthTest as unknown as number) || // Depth sort with a camera if able (!!camera && this._v.set(b.matrix[12], b.matrix[13], b.matrix[14]).applyMatrix4(camera.projectionViewMatrix).z - this._v.set(a.matrix[12], a.matrix[13], a.matrix[14]).applyMatrix4(camera.projectionViewMatrix).z) || // Reverse painter's sort transparent (a.material.transparent as unknown as number) - (b.material.transparent as unknown as number), ) } /** * Renders a scene of objects with an optional camera. */ render(scene: Object3D, camera?: Camera): void { // Compile render target let FBO = this._FBOs.get(this._renderTarget!) if (this._renderTarget && (!FBO || this._renderTarget.needsUpdate)) { FBO?.depthTexture.destroy() const views = this._renderTarget.textures.map((texture) => { this._updateTexture(texture, this._renderTarget!.width, this._renderTarget!.height) const target = this._textures.get(texture) as GPUTexture return target.createView() }) const depthTexture = this.device.createTexture({ size: [this._renderTarget.width, this._renderTarget.height, 1], format: 'depth24plus-stencil8', usage: GPU_TEXTURE_USAGE_RENDER_ATTACHMENT, }) const depthTextureView = depthTexture.createView() FBO = { views, depthTexture, depthTextureView } this._FBOs.set(this._renderTarget, FBO, () => depthTexture.destroy()) } // FBOs don't support multisampling ATM due to the WebGL complexity const samples = this.samples if (FBO) this.samples = 1 else if (this._msaaTexture.sampleCount !== samples) this._resizeSwapchain() const renderViews = FBO?.views ?? [this._msaaTextureView] const resolveTarget = FBO ? undefined : this.context.getCurrentTexture().createView() const loadOp: GPULoadOp = this.autoClear ? 'clear' : 'load' const storeOp: GPUStoreOp = 'store' this._commandEncoder = this.device.createCommandEncoder() this._passEncoder = this._commandEncoder.beginRenderPass({ colorAttachments: renderViews.map((view) => ({ view, resolveTarget, loadOp, storeOp, })), depthStencilAttachment: { view: FBO?.depthTextureView ?? this._depthTextureView, depthClearValue: 1, depthLoadOp: loadOp, depthStoreOp: storeOp, stencilClearValue: 0, stencilLoadOp: loadOp, stencilStoreOp: storeOp, }, }) if (this._renderTarget) this._passEncoder.setViewport(0, 0, this._renderTarget.width, this._renderTarget.height, 0, 1) else this._passEncoder.setViewport(0, 0, this.canvas.width, this.canvas.height, 0, 1) scene.updateMatrix() camera?.updateMatrix() if (camera?.matrixAutoUpdate) camera.projectionViewMatrix.normalNDC() const renderList = this.sort(scene, camera) for (const node of renderList) { this.compile(node, camera) // Alternate drawing for indexed and non-indexed children const { index, position } = node.geometry.attributes const { start, count } = node.geometry.drawRange if (index) this._passEncoder.drawIndexed(min(count, index.data.length / index.size), node.instances, start) else if (position) this._passEncoder.draw(min(count, position.data.length / position.size), node.instances, start) else this._passEncoder.draw(3, node.instances) } // Cleanup frame, submit GPU commands this._passEncoder.end() this.device.queue.submit([this._commandEncoder.finish()]) if (FBO) this.samples = samples } /** * Performs GPU compute on a given mesh. */ compute(node: Mesh): void { this._commandEncoder = this.device.createCommandEncoder() this._passEncoder = this._commandEncoder.beginComputePass() const workgroupCount: [number, number, number] = JSON.parse( `[${node.material.compute!.match(WORKGROUP_REGEX)![1]}]`, ) this.compile(node) this._passEncoder.dispatchWorkgroups(...workgroupCount) this._passEncoder.end() this.device.queue.submit([this._commandEncoder.finish()]) } }