diff --git a/sketches/2024.09.17-20.13.52.ts b/sketches/2024.09.17-20.13.52.ts new file mode 100644 index 0000000..2129f07 --- /dev/null +++ b/sketches/2024.09.17-20.13.52.ts @@ -0,0 +1,1045 @@ +// WebGPU demo +// NOTE: you must visit this on localhost or webgpu won't work + +/** + * + * EXPLORING POST-PROCESSING STYLE EFFECTS IN FRAGMENT SHADERS + * FIRST UP: EDGE DETECTION + * + */ + +/// + +import * as createCamera from '3d-view-controls' +import { createSpring } from 'spring-animator' +import { mat4, vec2 } from 'gl-matrix' +import { GUI } from 'dat-gui' + +const settings = { + cameraDist: 20000, + stiffness: 0.0005, + damping: 0.04 +} + +const BUILDINGS_DATA_URL = 'resources/data/nyc-buildings/lower-manhattan-sorted.bin' + +main() +async function main() { + const { device, context } = await setupWebGPU() + const canvas = context.canvas as HTMLCanvasElement + const curTexture = context.getCurrentTexture() + + let isCameraMoving = false + + console.log('loading buildings data...') + const result = await getBuildingsData(device, BUILDINGS_DATA_URL) + const { buffers, extent } = result + console.log('building data loaded') + + const center = [ + (extent.min[0] + extent.max[0]) / 2, + (extent.min[1] + extent.max[1]) / 2 + ] + + const cameraEye = [center[0] - 4, center[1], 70000] + const cameraCenter = [center[0], center[1], 0] + + const camera = createRoamingCamera({ + zoomSpeed: 4, + canvas, + center: cameraCenter, + eye: cameraEye, + damping: settings.damping, + stiffness: settings.stiffness, + getCameraPosition: () => ({ + center: [...center.map(v => v + (Math.random() - 0.5) * settings.cameraDist), Math.random() * 400], + height: Math.random() * 10000 + 500, + distance: Math.random() * 10000 + 1000, + angle: (Math.random() * 2 - 1) * Math.PI + }) + }) + + const gui = new GUI() + gui.add(settings, 'cameraDist', 0, 100000) + gui.add(settings, 'stiffness', 0, 0.001).step(0.0001) + gui.add(settings, 'damping', 0, 0.1).step(0.01) + gui.add({ next: () => camera.moveToNextPosition() }, 'next') + + console.log('center', center) + + const viewprojBindGroupLayout = device.createBindGroupLayout({ + entries: [{ + binding: 0, // viewproj + visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE, + buffer: {} + }] + }) + + // two mat4s = 32 floats + const viewprojData = new Float32Array(32) + const viewprojBuffer = createGPUBuffer(device, viewprojData.buffer, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST) + + const viewprojBindGroup = device.createBindGroup({ + layout: viewprojBindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: viewprojBuffer } } + ] + }) + + const shader = ` + struct Uniforms { + projection: mat4x4, + view: mat4x4 + }; + + @group(0) @binding(0) var uniforms: Uniforms; + + struct Output { + @builtin(position) position: vec4, + @location(0) color: vec4 + }; + + @vertex + fn mainVertex( + @location(0) position: vec3 + ) -> Output { + let p = position; + let t = smoothstep(-40, 500, p.z); + var color = vec4f(0.4, 0.4, 0.55, 1) + vec4f(0.2, 0.3, 0.35, 0) * t; + var output: Output; + output.color = color; + output.position = uniforms.projection * uniforms.view * vec4(p, 1); + return output; + } + + @fragment + fn mainFragment(@location(0) color: vec4) -> @location(0) vec4 { + return color; + }` + + const shaderModule = device.createShaderModule({ code: shader }) + const pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [viewprojBindGroupLayout] + }), + vertex: { + module: shaderModule, + entryPoint: 'mainVertex', + buffers: [ + { + arrayStride: 12, + attributes: [ + { + shaderLocation: 0, + format: 'float32x3' as GPUVertexFormat, + offset: 0 + } + ] + }, + ] + }, + fragment: { + module: shaderModule, + entryPoint: 'mainFragment', + targets: [{ format: curTexture.format }] + }, + primitive: { + topology: 'triangle-list', + frontFace: 'ccw', + cullMode: 'back' + }, + depthStencil: { + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less' + } + }) + + const depthTexture = device.createTexture({ + size: { width: curTexture.width, height: curTexture.height }, + format: 'depth24plus', + usage: GPUTextureUsage.RENDER_ATTACHMENT + }) + + let projMatrix = mat4.create() + + const indexGroupCount = 10000 + const indexGroups = getIndexGroups(indexGroupCount, result.data.positions, result.data.indexes) + + const culler = createGpuCuller(device, indexGroups, viewprojBindGroupLayout) + const indirectBuffer = culler.indirectDrawParamsBuffer + + const mainRenderBundleEncoder = device.createRenderBundleEncoder({ + colorFormats: ['bgra8unorm' as const], + depthStencilFormat: 'depth24plus' as const + }) + mainRenderBundleEncoder.setPipeline(pipeline) + mainRenderBundleEncoder.setBindGroup(0, viewprojBindGroup) + mainRenderBundleEncoder.setVertexBuffer(0, buffers.positions) + mainRenderBundleEncoder.setIndexBuffer(buffers.indexes, 'uint32') + for (let i = 0; i < indexGroupCount; i++) { + mainRenderBundleEncoder.drawIndexedIndirect(indirectBuffer, 20 * i) + } + const mainRenderBundle = mainRenderBundleEncoder.finish() + + const prepassTexture = device.createTexture({ + label: 'prepassTexture', + size: { width: canvas.width, height: canvas.height }, + format: 'bgra8unorm', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING + }) + const prepassDepthTexture = device.createTexture({ + label: 'prepassDepthTexture', + size: { width: canvas.width, height: canvas.height }, + format: 'depth24plus', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING + }) + + const postProcessRender = createPostProcessRenderer(device) + + requestAnimationFrame(function loop() { + const { width, height } = canvas // using canvas width/height because on safari the width/height of the texture doesn't seem to update when resizing + + camera._camera.up = [0, 0, 1] + camera.tick(settings.stiffness, settings.damping) + mat4.perspective(projMatrix, Math.PI / 8, width / height, 1, 1000000) + const view = camera.getMatrix() + + isCameraMoving = !mat4.equals(viewprojData.subarray(16), view) || !mat4.equals(viewprojData, projMatrix) + const isDirty = isCameraMoving + + if (isDirty) { + console.log('rendering') + viewprojData.set(projMatrix, 0) + viewprojData.set(view, 16) + device.queue.writeBuffer(viewprojBuffer, 0, viewprojData, 0, viewprojData.length) + + const curTexture = context.getCurrentTexture() + const curTextureView = curTexture.createView() + + const commandEncoder = device.createCommandEncoder() + + culler.cullGroups(commandEncoder, viewprojBindGroup) + + const prepassRenderPass = commandEncoder.beginRenderPass({ + colorAttachments: [{ + view: prepassTexture.createView(), + clearValue: { r: 0.02, g: 0, b: 0.1, a: 1 }, + loadOp: 'clear' as const, + storeOp: 'store' as const + }], + depthStencilAttachment: { + view: prepassDepthTexture.createView(), + depthClearValue: 1.0, + depthLoadOp: 'clear', + depthStoreOp: 'store' + } + }) + prepassRenderPass.executeBundles([mainRenderBundle]) + prepassRenderPass.end() + + const postRenderPass = commandEncoder.beginRenderPass({ + colorAttachments: [{ + view: curTextureView, + loadOp: 'load' as const, + storeOp: 'store' as const + }] + }) + postProcessRender(postRenderPass, prepassTexture, prepassDepthTexture, [canvas.width, canvas.height], [0, 0]) + postRenderPass.end() + + device.queue.submit([commandEncoder.finish()]) + } + + requestAnimationFrame(loop) + }) +} + +function createGpuCuller(device: GPUDevice, indexGroups: IndexGroup[], viewProjBindGroupLayout: GPUBindGroupLayout) { + const indirectDrawParams = new Uint32Array(5 * indexGroups.length) + const bBoxData = new Float32Array(indexGroups.length * 8) // minxyz_, maxxyz_ + const indexGroupDrawData = new Uint32Array(indexGroups.length) // indexCount + let i = 0 + for (const indexGroup of indexGroups) { + indirectDrawParams[i * 5] = indexGroup.indexCount + indirectDrawParams[i * 5 + 1] = 1 + indirectDrawParams[i * 5 + 2] = indexGroup.indexOffset + bBoxData.set(indexGroup.min, i * 8) + bBoxData.set(indexGroup.max, i * 8 + 4) + indexGroupDrawData[i] = indexGroup.indexCount + i++ + } + + const bBoxBuffer = createGPUBuffer(device, bBoxData.buffer, GPUBufferUsage.STORAGE) + const indexGroupDrawBuffer = createGPUBuffer(device, indexGroupDrawData.buffer, GPUBufferUsage.STORAGE) + const indirectDrawParamsBuffer = createGPUBuffer(device, indirectDrawParams.buffer, GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT) + + const shader = ` + struct Box { + min: vec3, + max: vec3, + } + + struct Plane { + normal: vec3, + distance: f32, + } + + fn get_plane(x: f32, y: f32, z: f32, w: f32) -> Plane { + let normal = vec3(x, y, z); + let inverse_normal_length = 1.0 / length(normal); + return Plane( + normal * inverse_normal_length, + w * inverse_normal_length + ); + } + + fn get_planes(m: mat4x4) -> array { + return array( + // right + get_plane(m[0][3] - m[0][0], m[1][3] - m[1][0], m[2][3] - m[2][0], m[3][3] - m[3][0]), + // left + get_plane(m[0][3] + m[0][0], m[1][3] + m[1][0], m[2][3] + m[2][0], m[3][3] + m[3][0]), + // bottom + get_plane(m[0][3] + m[0][1], m[1][3] + m[1][1], m[2][3] + m[2][1], m[3][3] + m[3][1]), + // top + get_plane(m[0][3] - m[0][1], m[1][3] - m[1][1], m[2][3] - m[2][1], m[3][3] - m[3][1]), + // z-far + get_plane(m[0][3] - m[0][2], m[1][3] - m[1][2], m[2][3] - m[2][2], m[3][3] - m[3][2]), + // z-near + get_plane(m[0][3] + m[0][2], m[1][3] + m[1][2], m[2][3] + m[2][2], m[3][3] + m[3][2]) + ); + } + + fn distance_to_point(plane: Plane, p: vec3) -> f32 { + return dot(plane.normal, p) + plane.distance; + } + + fn intersects_box(matrix: mat4x4, box: Box) -> bool { + let planes = get_planes(matrix); + for (var i: u32 = 0u; i < 6u; i++) { + let plane = planes[i]; + // corner at max distance + let p = vec3( + select(box.min.x, box.max.x, plane.normal.x > 0.0), + select(box.min.y, box.max.y, plane.normal.y > 0.0), + select(box.min.z, box.max.z, plane.normal.z > 0.0) + ); + + if (distance_to_point(plane, p) < 0.0) { + return false; + } + } + + return true; + } + + struct InputBbox { + min: vec4, + max: vec4, + }; + + struct Uniforms { + projection: mat4x4, + view: mat4x4 + }; + + @group(0) @binding(0) var uniforms: Uniforms; + + @group(1) @binding(1) var indexCounts: array; + @group(1) @binding(2) var boxes: array; + @group(1) @binding(3) var results: array; + + @compute @workgroup_size(64) + fn main(@builtin(global_invocation_id) global_id: vec3) { + let index = global_id.x; + if (index >= arrayLength(&boxes)) { + return; + } + + let matrix = uniforms.projection * uniforms.view; + let box = boxes[index]; + let indexCount = indexCounts[index]; + let isVisible = intersects_box(matrix, Box(box.min.xyz, box.max.xyz)); + results[index * 5] = select(0u, indexCount, isVisible); + } + ` + + const indexGroupsBindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 1, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'read-only-storage' as const } + }, + { + binding: 2, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'read-only-storage' as const } + }, + { + binding: 3, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'storage' as const } + } + ] + }) + + const computePipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [viewProjBindGroupLayout, indexGroupsBindGroupLayout] + }), + compute: { + module: device.createShaderModule({ code: shader }), + entryPoint: 'main' + } + }) + + const indexGroupsBindGroup = device.createBindGroup({ + layout: indexGroupsBindGroupLayout, + entries: [ + { binding: 1, resource: { buffer: indexGroupDrawBuffer } }, + { binding: 2, resource: { buffer: bBoxBuffer } }, + { binding: 3, resource: { buffer: indirectDrawParamsBuffer } } + ] + }) + + function cullGroups(commandEncoder: GPUCommandEncoder, viewProjBindGroup: GPUBindGroup) { + const computePass = commandEncoder.beginComputePass() + computePass.setPipeline(computePipeline) + computePass.setBindGroup(0, viewProjBindGroup) + computePass.setBindGroup(1, indexGroupsBindGroup) + computePass.dispatchWorkgroups(Math.ceil(indexGroups.length / 64)) + computePass.end() + } + + return { cullGroups, indirectDrawParamsBuffer } +} + +type IndexGroup = { + indexCount: number + indexOffset: number + min: [number, number, number] + max: [number, number, number] +} +function getIndexGroups(groupCount: number, vertices: Float32Array, indexes: Uint32Array): IndexGroup[] { + const indexCountPerDraw = Math.ceil(indexes.length / groupCount / 3) * 3 + const indexGroups = new Array() + let indexOffset = 0 + for (let i = 0; i < groupCount; i++) { + const indexCount = Math.min(indexCountPerDraw, indexes.length - indexOffset) + const min: [number, number, number] = [Infinity, Infinity, Infinity] + const max: [number, number, number] = [-Infinity, -Infinity, -Infinity] + for (let j = 0; j < indexCount; j++) { + const idx = indexes[indexOffset + j] * 3 + min[0] = Math.min(min[0], vertices[idx]) + min[1] = Math.min(min[1], vertices[idx + 1]) + min[2] = Math.min(min[2], vertices[idx + 2]) + max[0] = Math.max(max[0], vertices[idx]) + max[1] = Math.max(max[1], vertices[idx + 1]) + max[2] = Math.max(max[2], vertices[idx + 2]) + } + + indexGroups.push({ indexCount, indexOffset, min, max }) + indexOffset += indexCountPerDraw + } + return indexGroups +} + +// buildings data streamer + +type BuildingsData = { + indexCount: number + extent: { + min: [number, number, number] + max: [number, number, number] + } + data: { + buildingIds: Uint32Array + positions: Float32Array + indexes: Uint32Array + } + buffers: { + positions: GPUBuffer + indexes: GPUBuffer + buildingIds: GPUBuffer + } +} + +export const MAX_BUFFER_SIZE_BYTES = 268435456 // 256MiB +export const MAX_ARRAY_SIZE = Math.floor(MAX_BUFFER_SIZE_BYTES / 4 / 3 / 32) * 3 +export const MAX_ARRAY_SIZE_BYTES = MAX_ARRAY_SIZE * 4 + +if (MAX_ARRAY_SIZE % 3 !== 0) throw new Error('Array size is not divisible by 3, which is required for triangle lists') + +async function getBuildingsData (device: GPUDevice, url: string): Promise { + const response = await fetch(url) + if (!response.body) { + throw new Error('Unable to fetch data. No response.body.') + } + + const decoder = new StreamDecoder() + const reader = response.body.getReader() + + while (true) { + const result = await reader.read() + if (result?.value) decoder.onChunk(result.value) + if (result.done) break + } + + const { vertices, buildingIds, indexes, buildingCount, triangleCount, version, extent } = decoder.getCurrentResult()! + const positionsSample = vertices.slice(0, 18) + const indexSample = indexes.slice(0, 9) + const buildingIdsSample = buildingIds.slice(0, 18) + console.log({ buildingCount, triangleCount, version, positionsSample, indexSample, buildingIdsSample }) + + const positionsBuffer = createGPUBuffer(device, vertices.buffer, GPUBufferUsage.VERTEX) + const buildingIdsBuffer = createGPUBuffer(device, buildingIds.buffer, GPUBufferUsage.VERTEX) + const indexesBuffer = createGPUBuffer(device, indexes.buffer, GPUBufferUsage.INDEX) + + return { + indexCount: triangleCount * 3, + extent, + data: { + buildingIds, + positions: vertices, + indexes + }, + buffers: { + buildingIds: buildingIdsBuffer, + positions: positionsBuffer, + indexes: indexesBuffer + } + } +} + +/* + +Client-side code for decoding the following data format in Uint8 chunks. + +Usage: + +const decoder = new StreamDecoder() +decoder.onChunk(uint8Array) // call on every chunk (whether loading from a Socket or a BodyReader stream, etc) +const result: Result = decoder.getCurrentResult() +decoder.done // this will be true when all data expected from the header is done processing + +Data format (v0.1.0): + +---- HEADER ---- +version major - u8 +version minor - u8 +version patch - u8 +empty - u8 +vertexCount - uint32 +triangleCount - uint32 +buildingCount - uint32 +----- +buildingByteLength - uint32 +buildingId - uint32 +vertexCount - uint32 +vertexA - float32x3 +vertexB - float32x3 +... +triangleCount - uint32 +triA - uint8x3 (or uint16x3 if vertexCount > 255) +triB - uint8x3 (or uint16x3 if vertexCount > 255) +Possible padding here to make this list align to 4bytes +... +repeat with next building + +*/ + +type Result = { + vertices: Float32Array + indexes: Uint32Array + buildingIds: Uint32Array + buildingsProcessed: number + trianglesProcessed: number + verticesProcessed: number + buildingCount: number + triangleCount: number + version: string + extent: { + min: [number, number, number] + max: [number, number, number] + } +} + +const HEADER_SIZE = 16 + +class StreamDecoder { + private result: Result | null = null + private leftoverChunk: Uint8Array | null = null + public done: Boolean = false + public onChunk (chunk: Uint8Array): void { + chunk = this.mergeWithLeftoverChunk(chunk) + + const needsHeader = this.result === null + if (needsHeader) { + if (chunk.length < HEADER_SIZE) { + this.leftoverChunk = chunk.slice() // TODO: DO I NEED TO COPY THIS? + return + } + this.processHeader(new Uint8Array(chunk.buffer, chunk.byteOffset, HEADER_SIZE)) + chunk = new Uint8Array(chunk.buffer, chunk.byteOffset + HEADER_SIZE) + } + + while (true) { + if (chunk.length === 0) break + const dataview = new DataView(chunk.buffer, chunk.byteOffset) + const buildingByteLength = dataview.getUint32(0, true) + // see if this chunk contains the data for the entire building (minus 4 bytes for the + // buildingByteLength uint32 - which isn't included in the count) + // if not, then stick all of it in the leftoverChunk and try again on the next tick + if (chunk.length < buildingByteLength + 4) { + this.leftoverChunk = chunk.slice() // TODO: DO I NEED TO COPY THIS? + break + } + this.processBuilding(new Uint8Array(chunk.buffer, chunk.byteOffset + 4, buildingByteLength)) + chunk = new Uint8Array(chunk.buffer, chunk.byteOffset + 4 + buildingByteLength) + } + + if (this.result && this.result.buildingsProcessed === this.result.buildingCount) { + this.done = true + if (chunk.length !== 0) throw new Error('Decoding data failed: processed all buildings with data left over') + } + } + + private mergeWithLeftoverChunk (chunk: Uint8Array): Uint8Array { + if (!this.leftoverChunk || this.leftoverChunk.length === 0) { + return chunk + } + // TODO: CREATE A DATASTRUCTURE THAT HOLDS 2+ BUFFERS AND CAN PROCESS THEM WITHOUT + // HAVING TO DO COPIES TO MERGE THE TWO BUFFERS INTO ONE + const newChunk = new Uint8Array(this.leftoverChunk.length + chunk.length) + newChunk.set(this.leftoverChunk, 0) + newChunk.set(chunk, this.leftoverChunk.length) + this.leftoverChunk = null + return newChunk + } + + private processBuilding (chunk: Uint8Array): void { + if (!this.result) throw new Error('Decoding data failed: tried processing building before header') + + const dataview = new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength) + let i = 0 + const buildingId = dataview.getUint32(i, true) + const vertexCount = dataview.getUint32(i + 4, true) + const vertices = new Float32Array(chunk.buffer, chunk.byteOffset + 8, vertexCount * 3) + i += 8 + vertices.byteLength + const triangleCount = dataview.getUint32(i, true) + i += 4 + const TypedArray = vertexCount > 255 ? Uint16Array : Uint8Array + const triangles = new TypedArray(chunk.buffer, chunk.byteOffset + i, triangleCount * 3) + + const triangleListInBytes = TypedArray.BYTES_PER_ELEMENT * triangleCount * 3 + const expectedPadding = (4 - triangleListInBytes % 4) % 4 + // the chunk should have been completely consumed after the list of triangles + expectedPadding + if (chunk.byteLength !== i + triangles.byteLength + expectedPadding) { + throw new Error('Decoding data failed: building data has leftover bytes after processing') + } + + const lastVertex = this.result.verticesProcessed + const lastVertexIdx = lastVertex * 3 + this.result.vertices.set(vertices, lastVertexIdx) + + for (let k = 0; k < vertexCount; k++) { + this.result.extent.min[0] = Math.min(vertices[k * 3], this.result.extent.min[0]) + this.result.extent.min[1] = Math.min(vertices[k * 3 + 1], this.result.extent.min[1]) + this.result.extent.min[2] = Math.min(vertices[k * 3 + 2], this.result.extent.min[2]) + this.result.extent.max[0] = Math.max(vertices[k * 3], this.result.extent.max[0]) + this.result.extent.max[1] = Math.max(vertices[k * 3 + 1], this.result.extent.max[1]) + this.result.extent.max[2] = Math.max(vertices[k * 3 + 2], this.result.extent.max[2]) + } + + for (let k = 0; k < vertexCount; k++) { + this.result.buildingIds[this.result.verticesProcessed + k] = buildingId + } + + this.result.verticesProcessed += vertexCount + + for (let j = 0; j < triangles.length; j += 3) { + const idx = this.result.trianglesProcessed * 3 + this.result.indexes[idx] = triangles[j] + lastVertex + this.result.indexes[idx + 1] = triangles[j + 1] + lastVertex + this.result.indexes[idx + 2] = triangles[j + 2] + lastVertex + this.result.trianglesProcessed += 1 + } + + this.result.buildingsProcessed += 1 + } + + private processHeader (chunk: Uint8Array): void { + const dataview = new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength) + const version = `${chunk[0]}.${chunk[1]}.${chunk[2]}` + if (chunk[3] !== 0) throw new Error('Decoding data failed: invalid header') + const vertexCount = dataview.getUint32(4, true) + const triangleCount = dataview.getUint32(8, true) + const buildingCount = dataview.getUint32(12, true) + const indexCount = triangleCount * 3 + console.log({ version, triangleCount, buildingCount, vertexCount, indexCount }) + this.result = { + vertices: new Float32Array(vertexCount * 3), + indexes: new Uint32Array(indexCount), + buildingIds: new Uint32Array(vertexCount), + buildingsProcessed: 0, + trianglesProcessed: 0, + verticesProcessed: 0, + buildingCount, + triangleCount, + version, + extent: { + min: [Infinity, Infinity, Infinity], + max: [-Infinity, -Infinity, -Infinity] + } + } + } + + public getCurrentResult (): Result | null { + return this.result + } +} + +function createGPUBuffer( + device: GPUDevice, + data: ArrayBuffer, + usageFlag: GPUBufferUsageFlags, + byteOffset = 0, + byteLength = data.byteLength +) { + const buffer = device.createBuffer({ + size: byteLength, + usage: usageFlag, + mappedAtCreation: true + }) + new Uint8Array(buffer.getMappedRange()).set( + new Uint8Array(data, byteOffset, byteLength) + ) + buffer.unmap() + return buffer +} + +async function setupWebGPU(canvas?: HTMLCanvasElement) { + if (!window.navigator.gpu) { + const message = ` + Your current browser does not support WebGPU! Make sure you are on a system + with WebGPU enabled, e.g. Chrome or Safari (with the WebGPU flag enabled). + ` + document.body.innerText = message + throw new Error(message) + } + + const adapter = await window.navigator.gpu.requestAdapter() + if (!adapter) throw new Error('Failed to requestAdapter()') + + const device = await adapter.requestDevice() + if (!device) throw new Error('Failed to requestDevice()') + + if (!canvas) { + canvas = document.body.appendChild(document.createElement('canvas')) + window.addEventListener('resize', fit(canvas, document.body, window.devicePixelRatio), false) + } + + const context = canvas.getContext('webgpu') + if (!context) throw new Error('Failed to getContext("webgpu")') + + context.configure({ + device: device, + format: navigator.gpu.getPreferredCanvasFormat(), + alphaMode: 'opaque' + }) + + return { device, context } +} + +function fit(canvas: HTMLCanvasElement, parent: HTMLElement, scale = 1) { + const p = parent + + canvas.style.position = canvas.style.position || 'absolute' + canvas.style.top = '0' + canvas.style.left = '0' + return resize() + + function resize() { + let width = window.innerWidth + let height = window.innerHeight + if (p && p !== document.body) { + const bounds = p.getBoundingClientRect() + width = bounds.width + height = bounds.height + } + canvas.width = width * scale + canvas.height = height * scale + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + return resize + } +} + +// DEVELOPING ROAMING CAMERA 2.0 HERE + +type RoamingCameraOpts = { + canvas: HTMLCanvasElement + zoomSpeed: number + center: number[] + eye: number[] + getCameraPosition: () => { center: number[], height: number, distance: number, angle: number } + damping: number + stiffness: number + moveEveryNFrames?: number +} + +function createRoamingCamera(opts: RoamingCameraOpts) { + const { canvas, zoomSpeed, center, eye, getCameraPosition, damping, stiffness, moveEveryNFrames } = opts + let isRoaming = false + let frameCount = 0 + + canvas.addEventListener('mousedown', stopRoaming) + + const camera = createCamera(canvas, { + zoomSpeed: zoomSpeed + }) + + const values = getAnimatingValues(eye, center) + const centerSpring = createSpring(stiffness, damping, center) + const heightSpring = createSpring(stiffness, damping, values.height) + const distanceSpring = createSpring(stiffness, damping, values.distance) + const angleSpring = createSpring(stiffness, damping, values.angle) + + camera.lookAt( + eye, + center, + [0, 0, 1] + ) + camera.tick() + + setRandomCameraPosition() + + function setRandomCameraPosition () { + const { center, height, distance, angle } = getCameraPosition() + centerSpring.setDestination(center) + heightSpring.setDestination(height) + distanceSpring.setDestination(distance) + angleSpring.setDestination(angle) + frameCount = 0 + } + + function tick (s?: number, d?: number) { + if (isRoaming) { + centerSpring.tick(s, d) + heightSpring.tick(s, d) + distanceSpring.tick(s, d) + angleSpring.tick(s, d) + + const eye = getEyeFromAnimatingValues( + centerSpring.getCurrentValue(), + heightSpring.getCurrentValue(), + distanceSpring.getCurrentValue(), + angleSpring.getCurrentValue() + ) + + camera.lookAt( + eye, + centerSpring.getCurrentValue(), + [0, 0, 1] + ) + frameCount += 1 + if (moveEveryNFrames && frameCount >= moveEveryNFrames) { + setRandomCameraPosition() + } + } + camera.tick() + } + function getMatrix () { + return new Float32Array(camera.matrix) + } + function getCenter () { + return new Float32Array(camera.center) + } + function stopRoaming () { + isRoaming = false + frameCount = 0 + } + function startRoaming () { + setSpringsToCurrentCameraValues() + setRandomCameraPosition() + isRoaming = true + } + + function setSpringsToCurrentCameraValues () { + const values = getAnimatingValues(camera.eye, camera.center) + centerSpring.setDestination(camera.center, false) + heightSpring.setDestination(values.height, false) + distanceSpring.setDestination(values.distance, false) + angleSpring.setDestination(values.angle, false) + } + + (window as any).camera = camera + return { + tick, + getMatrix, + getCenter, + startRoaming, + stopRoaming, + _camera: camera, + moveToNextPosition: () => { + if (isRoaming) setRandomCameraPosition() + else startRoaming() + } + } +} + +function getAnimatingValues(eye: number[], center: number[]): { height: number, distance: number, angle: number } { + const height = eye[2] - center[2] + const distance = vec2.dist([eye[0], eye[1]], [center[0], center[1]]) + const angle = Math.atan2(eye[1] - center[1], eye[0] - center[0]) + return { height, distance, angle } +} + +function getEyeFromAnimatingValues(center: number[], height: number, distance: number, angle: number): number[] { + return [ + center[0] + Math.cos(angle) * distance, + center[1] + Math.sin(angle) * distance, + center[2] + height + ] +} + +function createPostProcessRenderer(device: GPUDevice) { + const shader = ` + struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) texCoord: vec2f, + } + + @vertex + fn mainVertex(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput { + var positions = array( + vec2f(-1, -1), + vec2f(-1, 1), + vec2f(1, -1), + vec2f(1, 1) + ); + + let p = positions[VertexIndex]; + let c = (vec2f(0, 1) - (p * 0.5 + 0.5)) * vec2f(-1, 1); + + var output: VertexOutput; + output.position = vec4f(p, 0, 1); + output.texCoord = c; + return output; + } + + @group(0) @binding(0) var dimensions: vec2f; + @group(0) @binding(1) var texSampler: sampler; + @group(0) @binding(2) var tex: texture_2d; + @group(0) @binding(3) var depth: texture_depth_2d; + + @fragment + fn mainFragment(@location(0) texCoord: vec2f) -> @location(0) vec4f { + var minValue = 1.0; + var maxValue = 0.0; + + const radius = 1.0; + for (var x = -radius; x <= radius; x += 1.0) { + for (var y = -radius; y <= radius; y += 1.0) { + let sample: f32 = textureLoad(depth, vec2(texCoord * dimensions + vec2f(x, y)), 0); + if (sample < minValue) { + minValue = sample; + } + if (sample > maxValue) { + maxValue = sample; + } + } + } + + let depth: f32 = textureLoad(depth, vec2(texCoord * dimensions), 0); + let color = textureSample(tex, texSampler, texCoord); + let diff = maxValue - minValue; + let a = mix(vec3f(0), color.rgb * 0.8, 1.0 - smoothstep(0.00001, 0.0001, diff)); + let b = color.rgb * 1.5; + let c = mix(a, b, smoothstep(0.0000005, 0.000005, diff)); + return vec4f(c, color.a); + } + ` + + const bindGroupLayout = device.createBindGroupLayout({ + label: 'post-processing', + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: {}, + }, + { + binding: 1, + visibility: GPUShaderStage.FRAGMENT, + sampler: {}, + }, + { + binding: 2, + visibility: GPUShaderStage.FRAGMENT, + texture: {}, + }, + { + binding: 3, + visibility: GPUShaderStage.FRAGMENT, + texture: { + sampleType: 'depth' as const, + viewDimension: '2d' as const, + }, + }, + ], + }); + + const shaderModule = device.createShaderModule({ code: shader }) + const pipeline = device.createRenderPipeline({ + label: 'post-processing', + layout: device.createPipelineLayout({ + label: 'post-processing', + bindGroupLayouts: [bindGroupLayout], + }), + vertex: { + module: shaderModule, + entryPoint: 'mainVertex', + }, + fragment: { + module: shaderModule, + entryPoint: 'mainFragment', + targets: [{ format: 'bgra8unorm' as const }] + }, + primitive: { + topology: 'triangle-strip', + }, + }) + + const sampler = device.createSampler({ + magFilter: 'linear', + minFilter: 'linear', + }) + + const bindGroupsByTexture = new Map() + return function renderBg(renderPass: GPURenderPassEncoder, texture: GPUTexture, depthTexture: GPUTexture, dimensions: [number, number], offset: [number, number] = [0, 0]) { + if (!bindGroupsByTexture.has(texture)) { + const dimensionsData = new Float32Array(dimensions) + console.log('dimensions:', dimensionsData) + const dimensionsBuffer = createGPUBuffer(device, dimensionsData.buffer, GPUBufferUsage.UNIFORM) + bindGroupsByTexture.set(texture, device.createBindGroup({ + label: 'post-processing-bind-group', + layout: bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: dimensionsBuffer } }, + { binding: 1, resource: sampler }, + { binding: 2, resource: texture.createView() }, + { binding: 3, resource: depthTexture.createView() }, + ] + })) + } + const bindGroup = bindGroupsByTexture.get(texture)! + + renderPass.setPipeline(pipeline) + renderPass.setBindGroup(0, bindGroup) + renderPass.setViewport(offset[0], offset[1], dimensions[0], dimensions[1], 0, 1) + renderPass.draw(4) + } +} diff --git a/sketches/2024.09.23-19.03.49.ts b/sketches/2024.09.23-19.03.49.ts new file mode 100644 index 0000000..8eabe80 --- /dev/null +++ b/sketches/2024.09.23-19.03.49.ts @@ -0,0 +1,1228 @@ +// WebGPU demo +// NOTE: you must visit this on localhost or webgpu won't work + +/** + * + * EXPLORING POST-PROCESSING STYLE EFFECTS IN FRAGMENT SHADERS + * FIRST UP: EDGE DETECTION + * + */ + +/// + +import * as createCamera from '3d-view-controls' +import { createSpring } from 'spring-animator' +import { mat4, vec2 } from 'gl-matrix' +import { GUI } from 'dat-gui' + +const BUILDINGS_DATA_URL = 'resources/data/nyc-buildings/lower-manhattan-sorted.bin' + +main() +async function main() { + const { device, context } = await setupWebGPU() + const canvas = context.canvas as HTMLCanvasElement + const curTexture = context.getCurrentTexture() + + let isCameraMoving = false + + console.log('loading buildings data...') + const result = await getBuildingsData(BUILDINGS_DATA_URL) + const { data, extent } = result + console.log('building data loaded') + + // add two empty vertices to the beginning and end of the index array + const indexesData = new Uint32Array(data.indexes.length + 4) + indexesData.set(data.indexes, 2) + + const positionsBuffer = createGPUBuffer(device, data.positions.buffer, GPUBufferUsage.STORAGE) + const indexesBuffer = createGPUBuffer(device, indexesData.buffer, GPUBufferUsage.VERTEX) + + const center = [ + (extent.min[0] + extent.max[0]) / 2, + (extent.min[1] + extent.max[1]) / 2 + ] + + const cameraEye = [center[0] - 4, center[1], 70000] + const cameraCenter = [center[0], center[1], 0] + + const settings = { + cameraDist: 20000, + stiffness: 0.0005, + damping: 0.04, + outlineAmount: 0.5, + outlineThresholdMin: 0.000001, + outlineThresholdMax: 0.000002, + inlineAmount: 0.1, + inlineThresholdMin: 1.2, + inlineThresholdMax: 2.5 + } + + const camera = createRoamingCamera({ + zoomSpeed: 4, + canvas, + center: cameraCenter, + eye: cameraEye, + damping: settings.damping, + stiffness: settings.stiffness, + getCameraPosition: () => ({ + center: [...center.map(v => v + (Math.random() - 0.5) * settings.cameraDist), Math.random() * 400], + height: Math.random() * 10000 + 500, + distance: Math.random() * 10000 + 1000, + angle: (Math.random() * 2 - 1) * Math.PI + }) + }) + + + let settingsChanged = false + + const gui = new GUI() + gui.add(settings, 'cameraDist', 0, 100000).onChange(() => settingsChanged = true) + gui.add(settings, 'stiffness', 0, 0.001).step(0.0001).onChange(() => settingsChanged = true) + gui.add(settings, 'damping', 0, 0.1).step(0.01).onChange(() => settingsChanged = true) + gui.add(settings, 'outlineAmount', 0, 1).step(0.01).onChange(() => settingsChanged = true) + gui.add(settings, 'outlineThresholdMin', 0, 0.000001).step(0.0000001).onChange(() => settingsChanged = true) + gui.add(settings, 'outlineThresholdMax', 0, 0.00001).step(0.0000001).onChange(() => settingsChanged = true) + gui.add(settings, 'inlineAmount', 0, 1).step(0.01).onChange(() => settingsChanged = true) + gui.add(settings, 'inlineThresholdMin', 0, 10).step(0.1).onChange(() => settingsChanged = true) + gui.add(settings, 'inlineThresholdMax', 0, 10).step(0.1).onChange(() => settingsChanged = true) + gui.add({ next: () => camera.moveToNextPosition() }, 'next') + + console.log('center', center) + + const viewprojBindGroupLayout = device.createBindGroupLayout({ + entries: [{ + binding: 0, // viewproj + visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE, + buffer: {} + }] + }) + + const positionsBindGroupLayout = device.createBindGroupLayout({ + entries: [{ + binding: 1, // positions + visibility: GPUShaderStage.VERTEX, + buffer: { type: 'read-only-storage' as const } + }] + }) + + // two mat4s = 32 floats + const viewprojData = new Float32Array(32) + const viewprojBuffer = createGPUBuffer(device, viewprojData.buffer, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST) + + const viewprojBindGroup = device.createBindGroup({ + layout: viewprojBindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: viewprojBuffer } } + ] + }) + + const positionsBindGroup = device.createBindGroup({ + layout: positionsBindGroupLayout, + entries: [ + { binding: 1, resource: { buffer: positionsBuffer } } + ] + }) + + const shader = ` + struct Uniforms { + projection: mat4x4, + view: mat4x4 + }; + + @group(0) @binding(0) var uniforms: Uniforms; + @group(1) @binding(1) var positions: array; + + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, + @location(1) @interpolate(flat) normal: vec3 + }; + + fn getNormal(p1: vec3, p2: vec3, p3: vec3) -> vec3 { + let e1 = p2 - p1; + let e2 = p3 - p1; + return normalize(cross(e1, e2)); + } + + @vertex + fn mainVertex( + @location(0) idxA: u32, + @location(1) idxB: u32, + @location(2) idxC: u32, + @location(3) idxD: u32, + @location(4) idxE: u32, + @builtin(vertex_index) vertexIndex: u32 + ) -> VertexOutput { + // 0th, 1st, or 2nd vertex of the triangle + let triIdx = vertexIndex % 3; + + // get the three vertices of the triangle + let indices = array(idxA, idxB, idxC, idxD, idxE); + let p1Idx = indices[2 - triIdx]; + let p2Idx = indices[3 - triIdx]; + let p3Idx = indices[4 - triIdx]; + + let p1 = vec3f(positions[p1Idx * 3], positions[p1Idx * 3 + 1], positions[p1Idx * 3 + 2]); + let p2 = vec3f(positions[p2Idx * 3], positions[p2Idx * 3 + 1], positions[p2Idx * 3 + 2]); + let p3 = vec3f(positions[p3Idx * 3], positions[p3Idx * 3 + 1], positions[p3Idx * 3 + 2]); + + var n = getNormal(p1, p2, p3); + + let p = vec3f(positions[idxC * 3], positions[idxC * 3 + 1], positions[idxC * 3 + 2]); + let t = smoothstep(-40, 500, p.z); + var color = vec4f(0.4, 0.4, 0.55, 1) + vec4f(0.2, 0.3, 0.35, 0) * t; + var output: VertexOutput; + output.color = color; + output.position = uniforms.projection * uniforms.view * vec4(p, 1); + output.normal = n; + return output; + } + + struct FragOutput { + @location(0) color: vec4, + @location(1) normal: vec4 + }; + + @fragment + fn mainFragment( + @location(0) color: vec4, + @location(1) @interpolate(flat) normal: vec3 + ) -> FragOutput { + var output: FragOutput; + output.color = vec4(color.rgb, 1); + output.normal = vec4(normal, 1); + return output; + }` + + const shaderModule = device.createShaderModule({ code: shader }) + const pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [ + viewprojBindGroupLayout, + positionsBindGroupLayout + ] + }), + vertex: { + module: shaderModule, + entryPoint: 'mainVertex', + buffers: [ + { + arrayStride: 4, + attributes: [{ + shaderLocation: 0, + format: 'uint32' as GPUVertexFormat, + offset: 0 + }] + }, + { + arrayStride: 4, + attributes: [{ + shaderLocation: 1, + format: 'uint32' as GPUVertexFormat, + offset: 0 + }] + }, + { + arrayStride: 4, + attributes: [{ + shaderLocation: 2, + format: 'uint32' as GPUVertexFormat, + offset: 0 + }] + }, + { + arrayStride: 4, + attributes: [{ + shaderLocation: 3, + format: 'uint32' as GPUVertexFormat, + offset: 0 + }] + }, + { + arrayStride: 4, + attributes: [{ + shaderLocation: 4, + format: 'uint32' as GPUVertexFormat, + offset: 0 + }] + }, + ] + }, + fragment: { + module: shaderModule, + entryPoint: 'mainFragment', + targets: [ + { format: 'bgra8unorm' as const }, + { format: 'bgra8unorm' as const } + ] + }, + primitive: { + topology: 'triangle-list', + // frontFace: 'ccw', + // cullMode: 'back' + }, + depthStencil: { + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less' + } + }) + + let projMatrix = mat4.create() + + const indexGroupCount = 2000 + const indexGroups = getIndexGroups(indexGroupCount, result.data.positions, result.data.indexes) + + const culler = createGpuCuller(device, indexGroups, viewprojBindGroupLayout) + const indirectBuffer = culler.indirectDrawParamsBuffer + + const mainRenderBundleEncoder = device.createRenderBundleEncoder({ + colorFormats: ['bgra8unorm' as const, 'bgra8unorm' as const], + depthStencilFormat: 'depth24plus' as const + }) + mainRenderBundleEncoder.setPipeline(pipeline) + mainRenderBundleEncoder.setBindGroup(0, viewprojBindGroup) + mainRenderBundleEncoder.setBindGroup(1, positionsBindGroup) + mainRenderBundleEncoder.setVertexBuffer(0, indexesBuffer) + mainRenderBundleEncoder.setVertexBuffer(1, indexesBuffer, 4) + mainRenderBundleEncoder.setVertexBuffer(2, indexesBuffer, 8) + mainRenderBundleEncoder.setVertexBuffer(3, indexesBuffer, 12) + mainRenderBundleEncoder.setVertexBuffer(4, indexesBuffer, 16) + for (let i = 0; i < indexGroupCount; i++) { + mainRenderBundleEncoder.drawIndirect(indirectBuffer, 16 * i) + } + const mainRenderBundle = mainRenderBundleEncoder.finish() + + const prepassTexture = device.createTexture({ + label: 'prepassTexture', + size: { width: canvas.width, height: canvas.height }, + format: 'bgra8unorm', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING + }) + const prepassNormalTexture = device.createTexture({ + label: 'prepassNormalTexture', + size: { width: canvas.width, height: canvas.height }, + format: 'bgra8unorm', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING + }) + const prepassDepthTexture = device.createTexture({ + label: 'prepassDepthTexture', + size: { width: canvas.width, height: canvas.height }, + format: 'depth24plus', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING + }) + + const postProcessRender = createPostProcessRenderer(device) + + requestAnimationFrame(function loop() { + const { width, height } = canvas // using canvas width/height because on safari the width/height of the texture doesn't seem to update when resizing + + camera._camera.up = [0, 0, 1] + camera.tick(settings.stiffness, settings.damping) + mat4.perspective(projMatrix, Math.PI / 8, width / height, 1, 1000000) + const view = camera.getMatrix() + + isCameraMoving = !mat4.equals(viewprojData.subarray(16), view) || !mat4.equals(viewprojData, projMatrix) + const isDirty = isCameraMoving || settingsChanged + settingsChanged = false + + if (isDirty) { + console.log('rendering') + viewprojData.set(projMatrix, 0) + viewprojData.set(view, 16) + device.queue.writeBuffer(viewprojBuffer, 0, viewprojData, 0, viewprojData.length) + + const curTexture = context.getCurrentTexture() + const curTextureView = curTexture.createView() + + const commandEncoder = device.createCommandEncoder() + + culler.cullGroups(commandEncoder, viewprojBindGroup) + + const prepassRenderPass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: prepassTexture.createView(), + clearValue: { r: 0.02, g: 0, b: 0.1, a: 1 }, + loadOp: 'clear' as const, + storeOp: 'store' as const + }, + { + view: prepassNormalTexture.createView(), + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + loadOp: 'clear' as const, + storeOp: 'store' as const + } + ], + depthStencilAttachment: { + view: prepassDepthTexture.createView(), + depthClearValue: 1.0, + depthLoadOp: 'clear', + depthStoreOp: 'store' + } + }) + prepassRenderPass.executeBundles([mainRenderBundle]) + prepassRenderPass.end() + + const postRenderPass = commandEncoder.beginRenderPass({ + colorAttachments: [{ + view: curTextureView, + loadOp: 'load' as const, + storeOp: 'store' as const + }] + }) + const { outlineAmount, outlineThresholdMin, outlineThresholdMax, inlineAmount, inlineThresholdMin, inlineThresholdMax } = settings + const inlineOutlineSettings: InlineOutlineVals = { outlineAmount, outlineThresholdMin, outlineThresholdMax, inlineAmount, inlineThresholdMin, inlineThresholdMax } + postProcessRender(postRenderPass, prepassTexture, prepassNormalTexture, prepassDepthTexture, inlineOutlineSettings, [canvas.width, canvas.height], [0, 0]) + postRenderPass.end() + + device.queue.submit([commandEncoder.finish()]) + } + + requestAnimationFrame(loop) + }) +} + +function createGpuCuller(device: GPUDevice, indexGroups: IndexGroup[], viewProjBindGroupLayout: GPUBindGroupLayout) { + const indirectDrawParams = new Uint32Array(4 * indexGroups.length) + const bBoxData = new Float32Array(indexGroups.length * 8) // minxyz_, maxxyz_ + const indexGroupDrawData = new Uint32Array(indexGroups.length) // indexCount + let i = 0 + for (const indexGroup of indexGroups) { + indirectDrawParams[i * 4] = indexGroup.indexCount // vertexCount + indirectDrawParams[i * 4 + 1] = 1 // instanceCount + indirectDrawParams[i * 4 + 2] = indexGroup.indexOffset // firstVertex + bBoxData.set(indexGroup.min, i * 8) + bBoxData.set(indexGroup.max, i * 8 + 4) + indexGroupDrawData[i] = indexGroup.indexCount + i++ + } + + // mainRenderBundleEncoder.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance) + // mainRenderBundleEncoder.draw(vertexCount, instanceCount, firstVertex, firstInstance) + + const bBoxBuffer = createGPUBuffer(device, bBoxData.buffer, GPUBufferUsage.STORAGE) + const indexGroupDrawBuffer = createGPUBuffer(device, indexGroupDrawData.buffer, GPUBufferUsage.STORAGE) + const indirectDrawParamsBuffer = createGPUBuffer(device, indirectDrawParams.buffer, GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT) + + const shader = ` + struct Box { + min: vec3, + max: vec3, + } + + struct Plane { + normal: vec3, + distance: f32, + } + + fn get_plane(x: f32, y: f32, z: f32, w: f32) -> Plane { + let normal = vec3(x, y, z); + let inverse_normal_length = 1.0 / length(normal); + return Plane( + normal * inverse_normal_length, + w * inverse_normal_length + ); + } + + fn get_planes(m: mat4x4) -> array { + return array( + // right + get_plane(m[0][3] - m[0][0], m[1][3] - m[1][0], m[2][3] - m[2][0], m[3][3] - m[3][0]), + // left + get_plane(m[0][3] + m[0][0], m[1][3] + m[1][0], m[2][3] + m[2][0], m[3][3] + m[3][0]), + // bottom + get_plane(m[0][3] + m[0][1], m[1][3] + m[1][1], m[2][3] + m[2][1], m[3][3] + m[3][1]), + // top + get_plane(m[0][3] - m[0][1], m[1][3] - m[1][1], m[2][3] - m[2][1], m[3][3] - m[3][1]), + // z-far + get_plane(m[0][3] - m[0][2], m[1][3] - m[1][2], m[2][3] - m[2][2], m[3][3] - m[3][2]), + // z-near + get_plane(m[0][3] + m[0][2], m[1][3] + m[1][2], m[2][3] + m[2][2], m[3][3] + m[3][2]) + ); + } + + fn distance_to_point(plane: Plane, p: vec3) -> f32 { + return dot(plane.normal, p) + plane.distance; + } + + fn intersects_box(matrix: mat4x4, box: Box) -> bool { + let planes = get_planes(matrix); + for (var i: u32 = 0u; i < 6u; i++) { + let plane = planes[i]; + // corner at max distance + let p = vec3( + select(box.min.x, box.max.x, plane.normal.x > 0.0), + select(box.min.y, box.max.y, plane.normal.y > 0.0), + select(box.min.z, box.max.z, plane.normal.z > 0.0) + ); + + if (distance_to_point(plane, p) < 0.0) { + return false; + } + } + + return true; + } + + struct InputBbox { + min: vec4, + max: vec4, + }; + + struct Uniforms { + projection: mat4x4, + view: mat4x4 + }; + + @group(0) @binding(0) var uniforms: Uniforms; + + @group(1) @binding(1) var indexCounts: array; + @group(1) @binding(2) var boxes: array; + @group(1) @binding(3) var results: array; + + @compute @workgroup_size(64) + fn main(@builtin(global_invocation_id) global_id: vec3) { + let index = global_id.x; + if (index >= arrayLength(&boxes)) { + return; + } + + let matrix = uniforms.projection * uniforms.view; + let box = boxes[index]; + let indexCount = indexCounts[index]; + let isVisible = intersects_box(matrix, Box(box.min.xyz, box.max.xyz)); + results[index * 4] = select(0u, indexCount, isVisible); + } + ` + + const indexGroupsBindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 1, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'read-only-storage' as const } + }, + { + binding: 2, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'read-only-storage' as const } + }, + { + binding: 3, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'storage' as const } + } + ] + }) + + const computePipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [viewProjBindGroupLayout, indexGroupsBindGroupLayout] + }), + compute: { + module: device.createShaderModule({ code: shader }), + entryPoint: 'main' + } + }) + + const indexGroupsBindGroup = device.createBindGroup({ + layout: indexGroupsBindGroupLayout, + entries: [ + { binding: 1, resource: { buffer: indexGroupDrawBuffer } }, + { binding: 2, resource: { buffer: bBoxBuffer } }, + { binding: 3, resource: { buffer: indirectDrawParamsBuffer } } + ] + }) + + function cullGroups(commandEncoder: GPUCommandEncoder, viewProjBindGroup: GPUBindGroup) { + const computePass = commandEncoder.beginComputePass() + computePass.setPipeline(computePipeline) + computePass.setBindGroup(0, viewProjBindGroup) + computePass.setBindGroup(1, indexGroupsBindGroup) + computePass.dispatchWorkgroups(Math.ceil(indexGroups.length / 64)) + computePass.end() + } + + return { cullGroups, indirectDrawParamsBuffer } +} + +type IndexGroup = { + indexCount: number + indexOffset: number + min: [number, number, number] + max: [number, number, number] +} +function getIndexGroups(groupCount: number, vertices: Float32Array, indexes: Uint32Array): IndexGroup[] { + const indexCountPerDraw = Math.ceil(indexes.length / groupCount / 3) * 3 + const indexGroups = new Array() + let indexOffset = 0 + for (let i = 0; i < groupCount; i++) { + const indexCount = Math.min(indexCountPerDraw, indexes.length - indexOffset) + const min: [number, number, number] = [Infinity, Infinity, Infinity] + const max: [number, number, number] = [-Infinity, -Infinity, -Infinity] + for (let j = 0; j < indexCount; j++) { + const idx = indexes[indexOffset + j] * 3 + min[0] = Math.min(min[0], vertices[idx]) + min[1] = Math.min(min[1], vertices[idx + 1]) + min[2] = Math.min(min[2], vertices[idx + 2]) + max[0] = Math.max(max[0], vertices[idx]) + max[1] = Math.max(max[1], vertices[idx + 1]) + max[2] = Math.max(max[2], vertices[idx + 2]) + } + + indexGroups.push({ indexCount, indexOffset, min, max }) + indexOffset += indexCountPerDraw + } + return indexGroups +} + +// buildings data streamer + +type BuildingsData = { + indexCount: number + extent: { + min: [number, number, number] + max: [number, number, number] + } + data: { + positions: Float32Array + indexes: Uint32Array + } +} + +export const MAX_BUFFER_SIZE_BYTES = 268435456 // 256MiB +export const MAX_ARRAY_SIZE = Math.floor(MAX_BUFFER_SIZE_BYTES / 4 / 3 / 32) * 3 + +if (MAX_ARRAY_SIZE % 3 !== 0) throw new Error('Array size is not divisible by 3, which is required for triangle lists') + +async function getBuildingsData (url: string): Promise { + const response = await fetch(url) + if (!response.body) { + throw new Error('Unable to fetch data. No response.body.') + } + + const decoder = new StreamDecoder() + const reader = response.body.getReader() + + while (true) { + const result = await reader.read() + if (result?.value) decoder.onChunk(result.value) + if (result.done) break + } + + const { vertices, buildingIds, indexes, buildingCount, triangleCount, version, extent } = decoder.getCurrentResult()! + const positionsSample = vertices.slice(0, 18) + const indexSample = indexes.slice(0, 9) + const buildingIdsSample = buildingIds.slice(0, 18) + console.log({ buildingCount, triangleCount, version, positionsSample, indexSample, buildingIdsSample }) + + return { + indexCount: triangleCount * 3, + extent, + data: { + positions: vertices, + indexes + } + } +} + +/* + +Client-side code for decoding the following data format in Uint8 chunks. + +Usage: + +const decoder = new StreamDecoder() +decoder.onChunk(uint8Array) // call on every chunk (whether loading from a Socket or a BodyReader stream, etc) +const result: Result = decoder.getCurrentResult() +decoder.done // this will be true when all data expected from the header is done processing + +Data format (v0.1.0): + +---- HEADER ---- +version major - u8 +version minor - u8 +version patch - u8 +empty - u8 +vertexCount - uint32 +triangleCount - uint32 +buildingCount - uint32 +----- +buildingByteLength - uint32 +buildingId - uint32 +vertexCount - uint32 +vertexA - float32x3 +vertexB - float32x3 +... +triangleCount - uint32 +triA - uint8x3 (or uint16x3 if vertexCount > 255) +triB - uint8x3 (or uint16x3 if vertexCount > 255) +Possible padding here to make this list align to 4bytes +... +repeat with next building + +*/ + +type Result = { + vertices: Float32Array + indexes: Uint32Array + buildingIds: Uint32Array + buildingsProcessed: number + trianglesProcessed: number + verticesProcessed: number + buildingCount: number + triangleCount: number + version: string + extent: { + min: [number, number, number] + max: [number, number, number] + } +} + +const HEADER_SIZE = 16 + +class StreamDecoder { + private result: Result | null = null + private leftoverChunk: Uint8Array | null = null + public done: Boolean = false + public onChunk (chunk: Uint8Array): void { + chunk = this.mergeWithLeftoverChunk(chunk) + + const needsHeader = this.result === null + if (needsHeader) { + if (chunk.length < HEADER_SIZE) { + this.leftoverChunk = chunk.slice() // TODO: DO I NEED TO COPY THIS? + return + } + this.processHeader(new Uint8Array(chunk.buffer, chunk.byteOffset, HEADER_SIZE)) + chunk = new Uint8Array(chunk.buffer, chunk.byteOffset + HEADER_SIZE) + } + + while (true) { + if (chunk.length === 0) break + const dataview = new DataView(chunk.buffer, chunk.byteOffset) + const buildingByteLength = dataview.getUint32(0, true) + // see if this chunk contains the data for the entire building (minus 4 bytes for the + // buildingByteLength uint32 - which isn't included in the count) + // if not, then stick all of it in the leftoverChunk and try again on the next tick + if (chunk.length < buildingByteLength + 4) { + this.leftoverChunk = chunk.slice() // TODO: DO I NEED TO COPY THIS? + break + } + this.processBuilding(new Uint8Array(chunk.buffer, chunk.byteOffset + 4, buildingByteLength)) + chunk = new Uint8Array(chunk.buffer, chunk.byteOffset + 4 + buildingByteLength) + } + + if (this.result && this.result.buildingsProcessed === this.result.buildingCount) { + this.done = true + if (chunk.length !== 0) throw new Error('Decoding data failed: processed all buildings with data left over') + } + } + + private mergeWithLeftoverChunk (chunk: Uint8Array): Uint8Array { + if (!this.leftoverChunk || this.leftoverChunk.length === 0) { + return chunk + } + // TODO: CREATE A DATASTRUCTURE THAT HOLDS 2+ BUFFERS AND CAN PROCESS THEM WITHOUT + // HAVING TO DO COPIES TO MERGE THE TWO BUFFERS INTO ONE + const newChunk = new Uint8Array(this.leftoverChunk.length + chunk.length) + newChunk.set(this.leftoverChunk, 0) + newChunk.set(chunk, this.leftoverChunk.length) + this.leftoverChunk = null + return newChunk + } + + private processBuilding (chunk: Uint8Array): void { + if (!this.result) throw new Error('Decoding data failed: tried processing building before header') + + const dataview = new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength) + let i = 0 + const buildingId = dataview.getUint32(i, true) + const vertexCount = dataview.getUint32(i + 4, true) + const vertices = new Float32Array(chunk.buffer, chunk.byteOffset + 8, vertexCount * 3) + i += 8 + vertices.byteLength + const triangleCount = dataview.getUint32(i, true) + i += 4 + const TypedArray = vertexCount > 255 ? Uint16Array : Uint8Array + const triangles = new TypedArray(chunk.buffer, chunk.byteOffset + i, triangleCount * 3) + + const triangleListInBytes = TypedArray.BYTES_PER_ELEMENT * triangleCount * 3 + const expectedPadding = (4 - triangleListInBytes % 4) % 4 + // the chunk should have been completely consumed after the list of triangles + expectedPadding + if (chunk.byteLength !== i + triangles.byteLength + expectedPadding) { + throw new Error('Decoding data failed: building data has leftover bytes after processing') + } + + const lastVertex = this.result.verticesProcessed + const lastVertexIdx = lastVertex * 3 + this.result.vertices.set(vertices, lastVertexIdx) + + for (let k = 0; k < vertexCount; k++) { + this.result.extent.min[0] = Math.min(vertices[k * 3], this.result.extent.min[0]) + this.result.extent.min[1] = Math.min(vertices[k * 3 + 1], this.result.extent.min[1]) + this.result.extent.min[2] = Math.min(vertices[k * 3 + 2], this.result.extent.min[2]) + this.result.extent.max[0] = Math.max(vertices[k * 3], this.result.extent.max[0]) + this.result.extent.max[1] = Math.max(vertices[k * 3 + 1], this.result.extent.max[1]) + this.result.extent.max[2] = Math.max(vertices[k * 3 + 2], this.result.extent.max[2]) + } + + for (let k = 0; k < vertexCount; k++) { + this.result.buildingIds[this.result.verticesProcessed + k] = buildingId + } + + this.result.verticesProcessed += vertexCount + + for (let j = 0; j < triangles.length; j += 3) { + const idx = this.result.trianglesProcessed * 3 + this.result.indexes[idx] = triangles[j] + lastVertex + this.result.indexes[idx + 1] = triangles[j + 1] + lastVertex + this.result.indexes[idx + 2] = triangles[j + 2] + lastVertex + this.result.trianglesProcessed += 1 + } + + this.result.buildingsProcessed += 1 + } + + private processHeader (chunk: Uint8Array): void { + const dataview = new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength) + const version = `${chunk[0]}.${chunk[1]}.${chunk[2]}` + if (chunk[3] !== 0) throw new Error('Decoding data failed: invalid header') + const vertexCount = dataview.getUint32(4, true) + const triangleCount = dataview.getUint32(8, true) + const buildingCount = dataview.getUint32(12, true) + const indexCount = triangleCount * 3 + console.log({ version, triangleCount, buildingCount, vertexCount, indexCount }) + this.result = { + vertices: new Float32Array(vertexCount * 3), + indexes: new Uint32Array(indexCount), + buildingIds: new Uint32Array(vertexCount), + buildingsProcessed: 0, + trianglesProcessed: 0, + verticesProcessed: 0, + buildingCount, + triangleCount, + version, + extent: { + min: [Infinity, Infinity, Infinity], + max: [-Infinity, -Infinity, -Infinity] + } + } + } + + public getCurrentResult (): Result | null { + return this.result + } +} + +function createGPUBuffer( + device: GPUDevice, + data: ArrayBuffer & { buffer?: never }, // make sure this is NOT a TypedArray + usageFlag: GPUBufferUsageFlags, + byteOffset = 0, + byteLength = data.byteLength +) { + const buffer = device.createBuffer({ + size: byteLength, + usage: usageFlag, + mappedAtCreation: true + }) + new Uint8Array(buffer.getMappedRange()).set( + new Uint8Array(data, byteOffset, byteLength) + ) + buffer.unmap() + return buffer +} + +async function setupWebGPU(canvas?: HTMLCanvasElement) { + if (!window.navigator.gpu) { + const message = ` + Your current browser does not support WebGPU! Make sure you are on a system + with WebGPU enabled, e.g. Chrome or Safari (with the WebGPU flag enabled). + ` + document.body.innerText = message + throw new Error(message) + } + + const adapter = await window.navigator.gpu.requestAdapter() + if (!adapter) throw new Error('Failed to requestAdapter()') + + const device = await adapter.requestDevice() + if (!device) throw new Error('Failed to requestDevice()') + + if (!canvas) { + canvas = document.body.appendChild(document.createElement('canvas')) + window.addEventListener('resize', fit(canvas, document.body, window.devicePixelRatio), false) + } + + const context = canvas.getContext('webgpu') + if (!context) throw new Error('Failed to getContext("webgpu")') + + context.configure({ + device: device, + format: navigator.gpu.getPreferredCanvasFormat(), + alphaMode: 'opaque' + }) + + return { device, context } +} + +function fit(canvas: HTMLCanvasElement, parent: HTMLElement, scale = 1) { + const p = parent + + canvas.style.position = canvas.style.position || 'absolute' + canvas.style.top = '0' + canvas.style.left = '0' + return resize() + + function resize() { + let width = window.innerWidth + let height = window.innerHeight + if (p && p !== document.body) { + const bounds = p.getBoundingClientRect() + width = bounds.width + height = bounds.height + } + canvas.width = width * scale + canvas.height = height * scale + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + return resize + } +} + +// DEVELOPING ROAMING CAMERA 2.0 HERE + +type RoamingCameraOpts = { + canvas: HTMLCanvasElement + zoomSpeed: number + center: number[] + eye: number[] + getCameraPosition: () => { center: number[], height: number, distance: number, angle: number } + damping: number + stiffness: number + moveEveryNFrames?: number +} + +function createRoamingCamera(opts: RoamingCameraOpts) { + const { canvas, zoomSpeed, center, eye, getCameraPosition, damping, stiffness, moveEveryNFrames } = opts + let isRoaming = false + let frameCount = 0 + + canvas.addEventListener('mousedown', stopRoaming) + + const camera = createCamera(canvas, { + zoomSpeed: zoomSpeed + }) + + const values = getAnimatingValues(eye, center) + const centerSpring = createSpring(stiffness, damping, center) + const heightSpring = createSpring(stiffness, damping, values.height) + const distanceSpring = createSpring(stiffness, damping, values.distance) + const angleSpring = createSpring(stiffness, damping, values.angle) + + camera.lookAt( + eye, + center, + [0, 0, 1] + ) + camera.tick() + + setRandomCameraPosition() + + function setRandomCameraPosition () { + const { center, height, distance, angle } = getCameraPosition() + centerSpring.setDestination(center) + heightSpring.setDestination(height) + distanceSpring.setDestination(distance) + angleSpring.setDestination(angle) + frameCount = 0 + } + + function tick (s?: number, d?: number) { + if (isRoaming) { + centerSpring.tick(s, d) + heightSpring.tick(s, d) + distanceSpring.tick(s, d) + angleSpring.tick(s, d) + + const eye = getEyeFromAnimatingValues( + centerSpring.getCurrentValue(), + heightSpring.getCurrentValue(), + distanceSpring.getCurrentValue(), + angleSpring.getCurrentValue() + ) + + camera.lookAt( + eye, + centerSpring.getCurrentValue(), + [0, 0, 1] + ) + frameCount += 1 + if (moveEveryNFrames && frameCount >= moveEveryNFrames) { + setRandomCameraPosition() + } + } + camera.tick() + } + function getMatrix () { + return new Float32Array(camera.matrix) + } + function getCenter () { + return new Float32Array(camera.center) + } + function stopRoaming () { + isRoaming = false + frameCount = 0 + } + function startRoaming () { + setSpringsToCurrentCameraValues() + setRandomCameraPosition() + isRoaming = true + } + + function setSpringsToCurrentCameraValues () { + const values = getAnimatingValues(camera.eye, camera.center) + centerSpring.setDestination(camera.center, false) + heightSpring.setDestination(values.height, false) + distanceSpring.setDestination(values.distance, false) + angleSpring.setDestination(values.angle, false) + } + + (window as any).camera = camera + return { + tick, + getMatrix, + getCenter, + startRoaming, + stopRoaming, + _camera: camera, + moveToNextPosition: () => { + if (isRoaming) setRandomCameraPosition() + else startRoaming() + } + } +} + +function getAnimatingValues(eye: number[], center: number[]): { height: number, distance: number, angle: number } { + const height = eye[2] - center[2] + const distance = vec2.dist([eye[0], eye[1]], [center[0], center[1]]) + const angle = Math.atan2(eye[1] - center[1], eye[0] - center[0]) + return { height, distance, angle } +} + +function getEyeFromAnimatingValues(center: number[], height: number, distance: number, angle: number): number[] { + return [ + center[0] + Math.cos(angle) * distance, + center[1] + Math.sin(angle) * distance, + center[2] + height + ] +} + +function createPostProcessRenderer(device: GPUDevice) { + const shader = ` + struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) texCoord: vec2f, + } + + @vertex + fn mainVertex(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput { + var positions = array( + vec2f(-1, -1), + vec2f(-1, 1), + vec2f(1, -1), + vec2f(1, 1) + ); + + let p = positions[VertexIndex]; + let c = (vec2f(0, 1) - (p * 0.5 + 0.5)) * vec2f(-1, 1); + + var output: VertexOutput; + output.position = vec4f(p, 0, 1); + output.texCoord = c; + return output; + } + + struct InlineOutlineVals { + outlineAmount: f32, + outlineThresholdMin: f32, + outlineThresholdMax: f32, + inlineAmount: f32, + inlineThresholdMin: f32, + inlineThresholdMax: f32, + } + + @group(0) @binding(0) var dimensions: vec2f; + @group(0) @binding(1) var texSampler: sampler; + @group(0) @binding(2) var tex: texture_2d; + @group(0) @binding(3) var normalTex: texture_2d; + @group(0) @binding(4) var depthTex: texture_depth_2d; + @group(1) @binding(5) var inlineOutlineVals: InlineOutlineVals; + + @fragment + fn mainFragment(@location(0) texCoord: vec2f) -> @location(0) vec4f { + let normal = textureSample(normalTex, texSampler, texCoord); + var normalDelta = vec3f(0); + + var minDepthValue = 1.0; + var maxDepthValue = 0.0; + + const radius = 1.0; + for (var x = -radius; x <= radius; x += 1.0) { + for (var y = -radius; y <= radius; y += 1.0) { + let normalSample = textureSample(normalTex, texSampler, texCoord + vec2f(x, y) / dimensions); + let sample: f32 = textureLoad(depthTex, vec2(texCoord * dimensions + vec2f(x, y)), 0); + if (sample < minDepthValue) { + minDepthValue = sample; + } + if (sample > maxDepthValue) { + maxDepthValue = sample; + } + normalDelta += abs(normalSample.xyz - normal.xyz); + } + } + + let depth: f32 = textureLoad(depthTex, vec2(texCoord * dimensions), 0); + let color = textureSample(tex, texSampler, texCoord); + let diff = maxDepthValue - minDepthValue; + let normalDiff = length(normalDelta); + let MIN_MULT = 0.85; + let OUTLINE_AMOUNT = inlineOutlineVals.outlineAmount; + let OUTLINE_THRESHOLD_MIN = inlineOutlineVals.outlineThresholdMin; + let OUTLINE_THRESHOLD_MAX = inlineOutlineVals.outlineThresholdMax; + let outlined = color.rgb * mix(MIN_MULT, MIN_MULT + OUTLINE_AMOUNT, smoothstep(OUTLINE_THRESHOLD_MIN, OUTLINE_THRESHOLD_MAX, diff)); + let INLINE_AMOUNT = inlineOutlineVals.inlineAmount; + let INLINE_THRESHOLD_MIN = inlineOutlineVals.inlineThresholdMin; + let INLINE_THRESHOLD_MAX = inlineOutlineVals.inlineThresholdMax; + let inlined = mix(outlined, outlined + vec3f(INLINE_AMOUNT), smoothstep(INLINE_THRESHOLD_MIN, INLINE_THRESHOLD_MAX, normalDiff)); + + return vec4f(inlined, color.a); + } + ` + + const bindGroupLayout = device.createBindGroupLayout({ + label: 'post-processing', + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: {}, + }, + { + binding: 1, + visibility: GPUShaderStage.FRAGMENT, + sampler: {}, + }, + { + binding: 2, + visibility: GPUShaderStage.FRAGMENT, + texture: {}, + }, + { + binding: 3, + visibility: GPUShaderStage.FRAGMENT, + texture: {}, + }, + { + binding: 4, + visibility: GPUShaderStage.FRAGMENT, + texture: { + sampleType: 'depth' as const, + viewDimension: '2d' as const, + }, + }, + ], + }); + + const inlineOutlineBindGroupLayout = device.createBindGroupLayout({ + label: 'post-processing-inline-outline-vals', + entries: [ + { + binding: 5, + visibility: GPUShaderStage.FRAGMENT, + buffer: {}, + }, + ], + }) + + const shaderModule = device.createShaderModule({ code: shader }) + const pipeline = device.createRenderPipeline({ + label: 'post-processing', + layout: device.createPipelineLayout({ + label: 'post-processing', + bindGroupLayouts: [bindGroupLayout, inlineOutlineBindGroupLayout], + }), + vertex: { + module: shaderModule, + entryPoint: 'mainVertex', + }, + fragment: { + module: shaderModule, + entryPoint: 'mainFragment', + targets: [{ format: 'bgra8unorm' as const }] + }, + primitive: { + topology: 'triangle-strip', + }, + }) + + const sampler = device.createSampler({ + magFilter: 'linear', + minFilter: 'linear', + }) + + const inlineOutlineValsData = new Float32Array([0, 0, 0, 0, 0, 0]) + const inlineOutlineBuffer = createGPUBuffer(device, inlineOutlineValsData.buffer, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST) + const inlineOutlineValsBindGroup = device.createBindGroup({ + label: 'post-processing-inline-outline-vals-bind-group', + layout: inlineOutlineBindGroupLayout, + entries: [ + { binding: 5, resource: { buffer: inlineOutlineBuffer } }, + ] + }) + + const bindGroupsByTexture = new Map() + return function renderBg(renderPass: GPURenderPassEncoder, texture: GPUTexture, normalTexture: GPUTexture, depthTexture: GPUTexture, inlineOutlineVals: InlineOutlineVals, dimensions: [number, number], offset: [number, number] = [0, 0]) { + if (!bindGroupsByTexture.has(texture)) { + const dimensionsData = new Float32Array(dimensions) + console.log('dimensions:', dimensionsData) + const dimensionsBuffer = createGPUBuffer(device, dimensionsData.buffer, GPUBufferUsage.UNIFORM) + bindGroupsByTexture.set(texture, device.createBindGroup({ + label: 'post-processing-bind-group', + layout: bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: dimensionsBuffer } }, + { binding: 1, resource: sampler }, + { binding: 2, resource: texture.createView() }, + { binding: 3, resource: normalTexture.createView() }, + { binding: 4, resource: depthTexture.createView() }, + ] + })) + } + const bindGroup = bindGroupsByTexture.get(texture)! + + inlineOutlineValsData[0] = inlineOutlineVals.outlineAmount + inlineOutlineValsData[1] = inlineOutlineVals.outlineThresholdMin + inlineOutlineValsData[2] = inlineOutlineVals.outlineThresholdMax + inlineOutlineValsData[3] = inlineOutlineVals.inlineAmount + inlineOutlineValsData[4] = inlineOutlineVals.inlineThresholdMin + inlineOutlineValsData[5] = inlineOutlineVals.inlineThresholdMax + device.queue.writeBuffer(inlineOutlineBuffer, 0, inlineOutlineValsData) + + renderPass.setPipeline(pipeline) + renderPass.setBindGroup(0, bindGroup) + renderPass.setBindGroup(1, inlineOutlineValsBindGroup) + renderPass.setViewport(offset[0], offset[1], dimensions[0], dimensions[1], 0, 1) + renderPass.draw(4) + } +} + +type InlineOutlineVals = { + outlineAmount: number + outlineThresholdMin: number + outlineThresholdMax: number + inlineAmount: number + inlineThresholdMin: number + inlineThresholdMax: number +} diff --git a/sketches/2024.10.06-20.20.46.ts b/sketches/2024.10.06-20.20.46.ts new file mode 100644 index 0000000..4868aaf --- /dev/null +++ b/sketches/2024.10.06-20.20.46.ts @@ -0,0 +1,221 @@ +// WebGPU demo +// NOTE: you must visit this on localhost or webgpu won't work +/// + +import { createSpring } from 'spring-animator' +import { vec2 } from 'gl-matrix' +import { GUI } from 'dat-gui' +import * as Delaunator from 'delaunator' + +main() +async function main() { + const { device, context } = await setupWebGPU() + + const settings = {} + const gui = new GUI() + + const pts: [number, number][] = [ + // [-1, -1], + // [-1, 1], + // [1, -1], + // [1, 1], + ] + + const colors: [number, number, number, number][] = [ + // [0, 0, 0, 1], + // [0, 0, 0, 1], + // [0, 0, 0, 1], + // [0, 0, 0, 1], + ] + + let aspect = context.canvas.width / context.canvas.height + + const ptCountWidth = 20 // of width + const ptCountHeight = Math.round(ptCountWidth / aspect) + for (let i = 0; i <= ptCountWidth; i++) { + for (let j = 0; j <= ptCountHeight; j++) { + const offset = i % 2 === 0 ? 0 : (2 / (ptCountHeight - 1) / 2) + pts.push([i / ptCountWidth * 2 - 1, j / ptCountHeight * 2 - 1 + offset]) + // colors.push([Math.random() * 0.5 + 0.5, Math.random() * 0.5 + 0.5, Math.random() * 0.5 + 0.5, 1]) + colors.push(Math.random() < 0.5 ? [Math.random() * 0.2 * 5, Math.random() * 0.1 * 5, Math.random() * 0.3 * 5, 1] : [0, 0, 0, 1]) + } + } + + const ptsData = new Float32Array(flatten(pts)) + const ptsBuffer = createGPUBuffer(device, ptsData.buffer, GPUBufferUsage.VERTEX) + + const colorsData = new Float32Array(flatten(colors)) + const colorsBuffer = createGPUBuffer(device, colorsData.buffer, GPUBufferUsage.VERTEX) + + const triangles = Delaunator.from(pts).triangles + const indexBuffer = createGPUBuffer(device, triangles.buffer, GPUBufferUsage.INDEX) + console.log(triangles) + + const shader = ` + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, + }; + + @vertex + fn mainVertex( + @location(0) position: vec2f, + @location(1) color: vec4f + ) -> VertexOutput { + var output: VertexOutput; + output.color = color; + output.position = vec4f(position, 0, 1); + return output; + } + + @fragment + fn mainFragment( + @location(0) color: vec4 + ) -> @location(0) vec4f { + return color; + }` + + const shaderModule = device.createShaderModule({ code: shader }) + const pipeline = device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: shaderModule, + entryPoint: 'mainVertex', + buffers: [ + { + arrayStride: 8, + attributes: [{ + shaderLocation: 0, + format: 'float32x2' as const, + offset: 0 + }] + }, + { + arrayStride: 16, + attributes: [{ + shaderLocation: 1, + format: 'float32x4' as const, + offset: 0 + }] + } + ] + }, + fragment: { + module: shaderModule, + entryPoint: 'mainFragment', + targets: [{ format: 'bgra8unorm' as const }] + }, + primitive: { + topology: 'triangle-list', + // topology: 'point-list', + } + }) + + requestAnimationFrame(function loop() { + const curTexture = context.getCurrentTexture() + + const commandEncoder = device.createCommandEncoder() + const renderPass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: curTexture.createView(), + clearValue: { r: 0.02, g: 0, b: 0.1, a: 1 }, + loadOp: 'clear' as const, + storeOp: 'store' as const + }, + ], + }) + renderPass.setPipeline(pipeline) + renderPass.setVertexBuffer(0, ptsBuffer) + renderPass.setVertexBuffer(1, colorsBuffer) + renderPass.setIndexBuffer(indexBuffer, 'uint32') + renderPass.drawIndexed(triangles.length, 1, 0, 0, 0) + renderPass.end() + + device.queue.submit([commandEncoder.finish()]) + + requestAnimationFrame(loop) + }) +} + +function createGPUBuffer( + device: GPUDevice, + data: ArrayBuffer & { buffer?: never }, // make sure this is NOT a TypedArray + usageFlag: GPUBufferUsageFlags, + byteOffset = 0, + byteLength = data.byteLength +) { + const buffer = device.createBuffer({ + size: byteLength, + usage: usageFlag, + mappedAtCreation: true + }) + new Uint8Array(buffer.getMappedRange()).set( + new Uint8Array(data, byteOffset, byteLength) + ) + buffer.unmap() + return buffer +} + +async function setupWebGPU(canvas?: HTMLCanvasElement) { + if (!window.navigator.gpu) { + const message = ` + Your current browser does not support WebGPU! Make sure you are on a system + with WebGPU enabled, e.g. Chrome or Safari (with the WebGPU flag enabled). + ` + document.body.innerText = message + throw new Error(message) + } + + const adapter = await window.navigator.gpu.requestAdapter() + if (!adapter) throw new Error('Failed to requestAdapter()') + + const device = await adapter.requestDevice() + if (!device) throw new Error('Failed to requestDevice()') + + if (!canvas) { + canvas = document.body.appendChild(document.createElement('canvas')) + window.addEventListener('resize', fit(canvas, document.body, window.devicePixelRatio), false) + } + + const context = canvas.getContext('webgpu') + if (!context) throw new Error('Failed to getContext("webgpu")') + + context.configure({ + device: device, + format: navigator.gpu.getPreferredCanvasFormat(), + alphaMode: 'opaque' + }) + + return { device, context } +} + +function fit(canvas: HTMLCanvasElement, parent: HTMLElement, scale = 1) { + const p = parent + + canvas.style.position = canvas.style.position || 'absolute' + canvas.style.top = '0' + canvas.style.left = '0' + return resize() + + function resize() { + let width = window.innerWidth + let height = window.innerHeight + if (p && p !== document.body) { + const bounds = p.getBoundingClientRect() + width = bounds.width + height = bounds.height + } + canvas.width = width * scale + canvas.height = height * scale + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + return resize + } +} + +function flatten(arr: T[][]): T[] { + return arr.reduce((flat, toFlatten) => { + return flat.concat(toFlatten) + }, []) +} diff --git a/sketches/2024.10.12-14.17.05.ts b/sketches/2024.10.12-14.17.05.ts new file mode 100644 index 0000000..43c3ee8 --- /dev/null +++ b/sketches/2024.10.12-14.17.05.ts @@ -0,0 +1,260 @@ +// WebGPU demo +// NOTE: you must visit this on localhost or webgpu won't work +/// + +import { mat4 } from 'gl-matrix' +import { GUI } from 'dat-gui' + +main() +async function main() { + const { device, context } = await setupWebGPU() + + const settings = {} + const gui = new GUI() + + const positions: number[] = [] + const colors: number[] = [] + const size = 20 + const spacing = 1 + const rows = 50 + const cols = 200 + + const totalWidth = cols * (size + spacing) + spacing + const totalHeight = rows * (size + spacing) + spacing + + for (let i = 0; i < cols; i++) { + for (let j = 0; j < rows; j++) { + // each position is the center of a cell, and there is spacing between cells and around the outside + const x = i * (size + spacing) + spacing + size / 2 + const y = j * (size + spacing) + spacing + size / 2 + positions.push(x - totalWidth / 2, y - totalHeight / 2) + colors.push(Math.random() * 0.4 + 0.4, Math.random() * 0.2 + 0.2, Math.random() * 0.5 + 0.5, 1) + } + } + + const verticesData = new Float32Array([-1, -1, -1, 1, 1, -1, 1, 1]) + const verticesBuffer = createGPUBuffer(device, verticesData.buffer, GPUBufferUsage.VERTEX) + + const positionsData = new Float32Array(positions) + const positionsBuffer = createGPUBuffer(device, positionsData.buffer, GPUBufferUsage.VERTEX) + + const colorsData = new Float32Array(colors) + const colorsBuffer = createGPUBuffer(device, colorsData.buffer, GPUBufferUsage.VERTEX) + + const projectionMatrix = mat4.ortho( + mat4.create(), + -context.canvas.width / 2, + context.canvas.width / 2, + -context.canvas.height / 2, + context.canvas.height / 2, + -1, 1 + ) + + const shader = ` + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, + }; + + struct Uniforms { + projectionMatrix: mat4x4, + size: f32, + }; + + @group(0) @binding(0) var uniforms: Uniforms; + + @vertex + fn mainVertex( + @location(0) pt: vec2f, + @location(1) position: vec2f, + @location(2) color: vec4f + ) -> VertexOutput { + let p = uniforms.size * 0.5 * pt + position; + var output: VertexOutput; + output.color = color; + output.position = uniforms.projectionMatrix * vec4f(p, 0, 1); + return output; + } + + @fragment + fn mainFragment( + @location(0) color: vec4 + ) -> @location(0) vec4f { + return color; + }` + + const projectionMatrixData = new Float32Array(projectionMatrix) + const bufferData = new Float32Array(projectionMatrixData.length + 4) + bufferData.set(projectionMatrixData) + bufferData[projectionMatrixData.length] = size + const projectionMatrixUniform = createGPUBuffer(device, bufferData.buffer, GPUBufferUsage.UNIFORM) + const projectionGroupLayout = device.createBindGroupLayout({ + entries: [{ + binding: 0, + visibility: GPUShaderStage.VERTEX, + buffer: {} + }] + }) + + const bufferGroup = device.createBindGroup({ + layout: projectionGroupLayout, + entries: [{ binding: 0, resource: { buffer: projectionMatrixUniform } }] + }) + + const shaderModule = device.createShaderModule({ code: shader }) + const pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [projectionGroupLayout] + }), + vertex: { + module: shaderModule, + entryPoint: 'mainVertex', + buffers: [ + { + arrayStride: 8, + stepMode: 'vertex' as const, + attributes: [{ + shaderLocation: 0, + format: 'float32x2' as const, + offset: 0 + }] + }, + { + arrayStride: 8, + stepMode: 'instance' as const, + attributes: [{ + shaderLocation: 1, + format: 'float32x2' as const, + offset: 0 + }] + }, + { + arrayStride: 16, + stepMode: 'instance' as const, + attributes: [{ + shaderLocation: 2, + format: 'float32x4' as const, + offset: 0 + }] + } + ] + }, + fragment: { + module: shaderModule, + entryPoint: 'mainFragment', + targets: [{ format: 'bgra8unorm' as const }] + }, + primitive: { + topology: 'triangle-strip', + } + }) + + requestAnimationFrame(function loop() { + const curTexture = context.getCurrentTexture() + + const commandEncoder = device.createCommandEncoder() + const renderPass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: curTexture.createView(), + clearValue: { r: 0.02, g: 0, b: 0.1, a: 1 }, + loadOp: 'clear' as const, + storeOp: 'store' as const + }, + ], + }) + renderPass.setPipeline(pipeline) + renderPass.setBindGroup(0, bufferGroup) + renderPass.setVertexBuffer(0, verticesBuffer) + renderPass.setVertexBuffer(1, positionsBuffer) + renderPass.setVertexBuffer(2, colorsBuffer) + renderPass.draw(4, rows * cols) + renderPass.end() + + device.queue.submit([commandEncoder.finish()]) + + requestAnimationFrame(loop) + }) +} + +function createGPUBuffer( + device: GPUDevice, + data: ArrayBuffer & { buffer?: never }, // make sure this is NOT a TypedArray + usageFlag: GPUBufferUsageFlags, + byteOffset = 0, + byteLength = data.byteLength +) { + const buffer = device.createBuffer({ + size: byteLength, + usage: usageFlag, + mappedAtCreation: true + }) + new Uint8Array(buffer.getMappedRange()).set( + new Uint8Array(data, byteOffset, byteLength) + ) + buffer.unmap() + return buffer +} + +async function setupWebGPU(canvas?: HTMLCanvasElement) { + if (!window.navigator.gpu) { + const message = ` + Your current browser does not support WebGPU! Make sure you are on a system + with WebGPU enabled, e.g. Chrome or Safari (with the WebGPU flag enabled). + ` + document.body.innerText = message + throw new Error(message) + } + + const adapter = await window.navigator.gpu.requestAdapter() + if (!adapter) throw new Error('Failed to requestAdapter()') + + const device = await adapter.requestDevice() + if (!device) throw new Error('Failed to requestDevice()') + + if (!canvas) { + canvas = document.body.appendChild(document.createElement('canvas')) + window.addEventListener('resize', fit(canvas, document.body, window.devicePixelRatio), false) + } + + const context = canvas.getContext('webgpu') + if (!context) throw new Error('Failed to getContext("webgpu")') + + context.configure({ + device: device, + format: navigator.gpu.getPreferredCanvasFormat(), + alphaMode: 'opaque' + }) + + return { device, context } +} + +function fit(canvas: HTMLCanvasElement, parent: HTMLElement, scale = 1) { + const p = parent + + canvas.style.position = canvas.style.position || 'absolute' + canvas.style.top = '0' + canvas.style.left = '0' + return resize() + + function resize() { + let width = window.innerWidth + let height = window.innerHeight + if (p && p !== document.body) { + const bounds = p.getBoundingClientRect() + width = bounds.width + height = bounds.height + } + canvas.width = width * scale + canvas.height = height * scale + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + return resize + } +} + +function flatten(arr: T[][]): T[] { + return arr.reduce((flat, toFlatten) => { + return flat.concat(toFlatten) + }, []) +} diff --git a/sketches/2024.10.18-11.56.38.ts b/sketches/2024.10.18-11.56.38.ts new file mode 100644 index 0000000..0662604 --- /dev/null +++ b/sketches/2024.10.18-11.56.38.ts @@ -0,0 +1,316 @@ +// WebGPU demo +// NOTE: you must visit this on localhost or webgpu won't work +/// + +import { mat4 } from 'gl-matrix' +import { GUI } from 'dat-gui' + +const palettes = [ + [166, 124, 135, 255], + [63, 85, 115, 255], + [106, 138, 166, 255], + [218, 182, 182, 255], + [242, 228, 228, 255], + [101, 105, 117, 255], + [116, 93, 113, 255] +] + +const MAX_GRID_DIM_SIZE = 64 + +// -------------------------------------- +// NEXT TODO: +// - use different bind groups for uniforms and cells +// - make a uniforms manager that can create uniforms buffers and update them +// and create bind groups / layouts and maybe even keep the WGSL shader code +// uniforms struct in sync with the uniforms manager +// - add animations for cell size and color +// - improve performance by rendering each cell as an instance +// -------------------------------------- + +main() +async function main() { + const { device, context } = await setupWebGPU() + + const settings = { + gridWidth: 8, + gridHeight: 9, + cellSize: 60, + cellSpacing: 105, + squircleK: 3.7, + curColIdx: 0 + } + + const gui = new GUI() + gui.add(settings, 'gridWidth', 1, MAX_GRID_DIM_SIZE).name('Grid Width').step(1) + gui.add(settings, 'gridHeight', 1, MAX_GRID_DIM_SIZE).name('Grid Height').step(1) + gui.add(settings, 'cellSize', 1, 400).name('Cell Size').step(1) + gui.add(settings, 'cellSpacing', 0, 400).name('Cell Spacing').step(1) + gui.add(settings, 'squircleK', 1, 20).name('Squircle K').step(0.1) + gui.add(settings, 'curColIdx', 0, 32).name('Current Column').step(1) + + const verticesData = new Float32Array([-1, -1, -1, 1, 1, -1, 1, 1]) + const verticesBuffer = createGPUBuffer(device, verticesData.buffer, GPUBufferUsage.VERTEX) + + const shader = ` + struct VertexOutput { + @builtin(position) position: vec4, + }; + + struct Uniforms { + dimensions: vec2f, + gridDimensions: vec2f, + cellSize: f32, + cellSpacing: f32, + squircleK: f32, + curColIdx: f32, + palettes: array, + }; + + @group(0) @binding(0) var uniforms: Uniforms; + @group(0) @binding(1) var cells: array>; + + @vertex + fn mainVertex( + @location(0) position: vec2f + ) -> @builtin(position) vec4 { + return vec4f(position, 0, 1); + } + + fn getCellPosition(ij: vec2f) -> vec2f { + return ij * uniforms.cellSpacing + uniforms.cellSize / 2.0; + } + + fn squircle(p: vec2f, center: vec2f, size: vec2f, k: f32) -> f32 { + let q = abs(p - center) / size; + return pow(pow(q.x, k) + pow(q.y, k), 1.0 / k); + } + + @fragment + fn mainFragment( + @builtin(position) position: vec4 + ) -> @location(0) vec4f { + // grid is just cell spacing * grid dimensions - 1 + cell size + let gridDims = uniforms.cellSize + (uniforms.gridDimensions - 1.0) * uniforms.cellSpacing; + let gridOffset = (uniforms.dimensions - gridDims) / 2.0; + let cellDims = vec2f(uniforms.cellSize, uniforms.cellSize); + let gridBuffer = gridDims * 0.1; + let minXY = gridOffset - gridBuffer; + let maxXY = gridOffset + gridDims + gridBuffer; + + // if position is outside of grid, return white + if (position.x < minXY.x || position.x > maxXY.x || position.y < minXY.y || position.y > maxXY.y) { + return vec4f(1.0); + } + + // rgb is weighted sum of colors, a is sum of weights + var color: vec4f = vec4f(0.0, 0.0, 0.0, 0.01); + for (var i = 0.0; i < uniforms.gridDimensions.x; i += 1.0) { + let isCur: f32 = step(abs(i - uniforms.curColIdx), 0.001); + for (var j = 0.0; j < uniforms.gridDimensions.y; j += 1.0) { + let cellIdx = u32(j * uniforms.gridDimensions.x + i); + let cell = cells[cellIdx]; + let cellColor: vec4f = uniforms.palettes[cell.y]; + let fadedColor = vec4f(mix(vec3f(1.0), cellColor.rgb, 0.4), 1.0); + let cellPosition = getCellPosition(vec2f(i, j)) + gridOffset; + let t = squircle(position.xy, cellPosition, cellDims, uniforms.squircleK); + let weight = 1.0 - smoothstep(0.0, 1.0, t); + let curColor = mix(fadedColor, cellColor, isCur); + color += curColor * weight * f32(cell.x); + } + } + + let breakpoint = 0.12; + let fade = 0.008; + return mix(vec4f(1.0), color / color.a, smoothstep(breakpoint - fade, breakpoint + fade, color.a)); + }` + + const cellData = new Uint32Array(MAX_GRID_DIM_SIZE * MAX_GRID_DIM_SIZE * 2) + let n = 0 + while (n < cellData.length) { + // on or off + cellData[n++] = Math.random() > 0.5 ? 1.0 : 0.0 + // palette color index + cellData[n++] = Math.random() * palettes.length | 0 + } + console.log(cellData) + const cellBuffer = createGPUBuffer(device, cellData.buffer, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST) + + const uniformsData = new Float32Array([ + context.canvas.width, context.canvas.height, + settings.gridWidth, settings.gridHeight, + settings.cellSize, settings.cellSpacing, + settings.squircleK, settings.curColIdx % settings.gridWidth, + ...flatten(palettes).map(c => c / 255) + ]) + const uniformsBuffer = createGPUBuffer(device, uniformsData.buffer, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST) + const uniformsGroupLayout = device.createBindGroupLayout({ + entries: [{ + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: 'uniform' as const } + }, { + binding: 1, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: 'read-only-storage' as const } + }] + }) + + const bindGroup = device.createBindGroup({ + layout: uniformsGroupLayout, + entries: [ + { binding: 0, resource: { buffer: uniformsBuffer } }, + { binding: 1, resource: { buffer: cellBuffer } } + ] + }) + + const shaderModule = device.createShaderModule({ code: shader }) + const pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [uniformsGroupLayout] + }), + vertex: { + module: shaderModule, + entryPoint: 'mainVertex', + buffers: [{ + arrayStride: 8, + stepMode: 'vertex' as const, + attributes: [{ + shaderLocation: 0, + format: 'float32x2' as const, + offset: 0 + }] + }] + }, + fragment: { + module: shaderModule, + entryPoint: 'mainFragment', + targets: [{ format: 'bgra8unorm' as const }] + }, + primitive: { + topology: 'triangle-strip', + } + }) + + requestAnimationFrame(function loop() { + // for (let i = 0; i < cellData.length; i++) { + // cellData[i] = Math.random() + // } + // device.queue.writeBuffer(cellBuffer, 0, cellData) + + // update uniforms + uniformsData[0] = context.canvas.width + uniformsData[1] = context.canvas.height + uniformsData[2] = settings.gridWidth + uniformsData[3] = settings.gridHeight + uniformsData[4] = settings.cellSize + uniformsData[5] = settings.cellSpacing + uniformsData[6] = settings.squircleK + uniformsData[7] = settings.curColIdx % settings.gridWidth + device.queue.writeBuffer(uniformsBuffer, 0, uniformsData) + + const curTexture = context.getCurrentTexture() + + const commandEncoder = device.createCommandEncoder() + const renderPass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: curTexture.createView(), + clearValue: { r: 1, g: 1, b: 1, a: 1 }, + loadOp: 'clear' as const, + storeOp: 'store' as const + }, + ], + }) + renderPass.setPipeline(pipeline) + renderPass.setBindGroup(0, bindGroup) + renderPass.setVertexBuffer(0, verticesBuffer) + renderPass.draw(4) + renderPass.end() + + device.queue.submit([commandEncoder.finish()]) + + requestAnimationFrame(loop) + }) +} + +function createGPUBuffer( + device: GPUDevice, + data: ArrayBuffer & { buffer?: never }, // make sure this is NOT a TypedArray + usageFlag: GPUBufferUsageFlags, + byteOffset = 0, + byteLength = data.byteLength +) { + const buffer = device.createBuffer({ + size: byteLength, + usage: usageFlag, + mappedAtCreation: true + }) + new Uint8Array(buffer.getMappedRange()).set( + new Uint8Array(data, byteOffset, byteLength) + ) + buffer.unmap() + return buffer +} + +async function setupWebGPU(canvas?: HTMLCanvasElement) { + if (!window.navigator.gpu) { + const message = ` + Your current browser does not support WebGPU! Make sure you are on a system + with WebGPU enabled, e.g. Chrome or Safari (with the WebGPU flag enabled). + ` + document.body.innerText = message + throw new Error(message) + } + + const adapter = await window.navigator.gpu.requestAdapter() + if (!adapter) throw new Error('Failed to requestAdapter()') + + const device = await adapter.requestDevice() + if (!device) throw new Error('Failed to requestDevice()') + + if (!canvas) { + canvas = document.body.appendChild(document.createElement('canvas')) + window.addEventListener('resize', fit(canvas, document.body, window.devicePixelRatio), false) + } + + const context = canvas.getContext('webgpu') + if (!context) throw new Error('Failed to getContext("webgpu")') + + context.configure({ + device: device, + format: navigator.gpu.getPreferredCanvasFormat(), + alphaMode: 'opaque' + }) + + return { device, context } +} + +function fit(canvas: HTMLCanvasElement, parent: HTMLElement, scale = 1) { + const p = parent + + canvas.style.position = canvas.style.position || 'absolute' + canvas.style.top = '0' + canvas.style.left = '0' + return resize() + + function resize() { + let width = window.innerWidth + let height = window.innerHeight + if (p && p !== document.body) { + const bounds = p.getBoundingClientRect() + width = bounds.width + height = bounds.height + } + canvas.width = width * scale + canvas.height = height * scale + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + return resize + } +} + +function flatten(arr: T[][]): T[] { + return arr.reduce((flat, toFlatten) => { + return flat.concat(toFlatten) + }, []) +}