diff --git a/webgpu/lessons/webgpu-fundamentals.js b/webgpu/lessons/webgpu-fundamentals.js new file mode 100644 index 00000000..7e4b4f21 --- /dev/null +++ b/webgpu/lessons/webgpu-fundamentals.js @@ -0,0 +1,289 @@ +import { + renderDiagrams +} from './resources/diagrams.js'; +import { + createElem as el, +} from './resources/elem.js'; +import { SVG as svg } from '/3rdparty/svg.esm.js'; +import { clamp } from './resources/utils.js'; + +const darkColors = { + main: '#fff', + outline: '#000', + point: '#80DDFF80', + clear: [0.3, 0.3, 0.3, 1], + pixel: [1, 0, 0, 1], + dots: 'yellow', + grid: '#000', + handle: 'rgba(255, 255, 255, 0.5)', +}; +const lightColors = { + main: '#000', + outline: '#fff', + point: '#8000FF20', + clear: [0.9, 0.9, 0.9, 1], + pixel: [1, 0.25, 0.25, 1], + dots: 'blue', + grid: '#888', + handle: 'rgba(0, 0, 0, 0.25)', +}; +const darkMatcher = window.matchMedia('(prefers-color-scheme: dark)'); +let colorScheme; + +const updateColorScheme = () => { + const isDarkMode = darkMatcher.matches; + colorScheme = isDarkMode ? darkColors : lightColors; + //hLine.stroke(colorScheme.main); + //vLine.stroke(colorScheme.main); + //marker.fill(colorScheme.main); + //pointOuter.stroke(colorScheme.main); + //pointInner.fill(colorScheme.point); +}; +updateColorScheme(); + +const devicePromise = navigator.gpu?.requestAdapter() + .then(adapter => adapter.requestDevice()); + +const makeText = (parent, t) => { + return parent.text(t) + .font({ + family: 'monospace', + weight: 'bold', + size: '20', + anchor: 'middle', + }) + .fill(colorScheme.main) + .css({ + 'user-select': 'none', + filter: ` + drop-shadow( 1px 0px 0px ${colorScheme.outline}) + drop-shadow( 0px 1px 0px ${colorScheme.outline}) + drop-shadow(-1px 0px 0px ${colorScheme.outline}) + drop-shadow( 0px -1px 0px ${colorScheme.outline}) + `, + }); +}; + +async function showClipSpaceToTexels({webgpuCanvas, infoCanvas}) { + const device = await devicePromise; + + webgpuCanvas.width = 15; + webgpuCanvas.height = 11; + + const context = webgpuCanvas.getContext('webgpu'); + const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); + context.configure({ + device, + format: presentationFormat, + }); + + const points = [ + [ 0.0, 0.5, ], + [ -0.5, -0.5, ], + [ 0.5, -0.5, ], + ]; + + const module = device.createShaderModule({ + label: 'our hardcoded "a" triangle', + code: ` + struct OurVertexShaderOutput { + @builtin(position) position: vec4f, + @location(0) texcoord: vec2f, + }; + + @vertex fn vs( + @location(0) xy: vec2f + ) -> OurVertexShaderOutput { + var vsOutput: OurVertexShaderOutput; + vsOutput.position = vec4f(xy, 0.0, 1.0); + return vsOutput; + } + + @fragment fn fs() -> @location(0) vec4f { + return vec4f(${colorScheme.pixel}); + } + `, + }); + + const pipeline = device.createRenderPipeline({ + label: 'hardcoded textured quad pipeline', + layout: 'auto', + vertex: { + module, + entryPoint: 'vs', + buffers: [ + { + arrayStride: 8, + attributes: [ + {shaderLocation: 0, offset: 0, format: 'float32x2'}, + ], + }, + ], + }, + fragment: { + module, + entryPoint: 'fs', + targets: [{ format: presentationFormat }], + }, + }); + + const vertexBuffer = device.createBuffer({ + size: 3 * 2 * 4, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + + const renderPassDescriptor = { + label: 'our basic canvas renderPass', + colorAttachments: [ + { + // view: <- to be filled out when we render + clearValue: colorScheme.clear, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }; + + const svgWidth = 750; + const svgHeight = 550; + const draw = svg().addTo(infoCanvas).viewbox(0, 0, svgWidth, svgHeight); + const grid = draw.group().stroke(colorScheme.grid); + + const clipSpaceToSVG = (x, y) => { + return [(x * 0.5 + 0.5) * svgWidth + 0.5, (-y * 0.5 + 0.5) * svgHeight + 0.5]; + }; + const clipSpaceToSVGPathPart = (x, y) => { + return clipSpaceToSVG(x, y).join(' '); + }; + + const gridPath = []; + for (let y = 1; y < webgpuCanvas.height; ++y) { + const yy = y / webgpuCanvas.height * 2 - 1; + gridPath.push(`M${clipSpaceToSVGPathPart(-1, yy)} L${clipSpaceToSVGPathPart(1, yy)}`); + } + for (let x = 1; x < webgpuCanvas.width; ++x) { + const xx = x / webgpuCanvas.width * 2 - 1; + gridPath.push(`M${clipSpaceToSVGPathPart(xx, 1)} L${clipSpaceToSVGPathPart(xx, -1)}`); + } + grid.path(gridPath.join(' ')); + + const dots = draw.group().fill(colorScheme.dots); + for (let y = 0; y < webgpuCanvas.height; ++y) { + for (let x = 0; x < webgpuCanvas.width; ++x) { + dots.circle(3).center( + (x + 0.5) / webgpuCanvas.width * svgWidth, + (y + 0.5) / webgpuCanvas.height * svgHeight); + } + } + + const triangle = draw.path('').stroke(colorScheme.main).fill('none'); + const triPoints = points.map((_, i) => { + const group = draw.group(); + const rect = group.rect(20, 20).center(0, 0).fill(colorScheme.handle); + rect.on('pointerdown', (e) => onDown(e, i), {passive: false}); + return { + group, + text: makeText(group, `p${i}`), + }; + }); + + let handleNdx; + let startMousePos; + let startHandlePos; + + const svgSize = [svgWidth, svgHeight]; + const clipFlip = [1, -1]; + + function getRelativePointerPosition(e) { + const rect = draw.node.getBoundingClientRect(); + return [ + /*e.offsetX*/ (e.clientX - rect.left) * svgWidth / draw.node.clientWidth, + /*e.offsetY*/ (e.clientY - rect.top ) * svgHeight / draw.node.clientHeight, + ]; + } + + function getRelativePointerClipSpacePosition(e) { + return getRelativePointerPosition(e).map((v, i) => (v / svgSize[i] * 2 - 1) * clipFlip[i]); + } + + + function onMove(e) { + e.preventDefault(); + + const p = getRelativePointerClipSpacePosition(e); + points[handleNdx] = startHandlePos.map((startHandlePos, ndx) => + clamp(p[ndx] - startMousePos[ndx] + startHandlePos, -1, 1) + ); + + render(); + } + + function onUp() { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + } + + function onDown(e, _handleNdx) { + startMousePos = getRelativePointerClipSpacePosition(e); + startHandlePos = points[_handleNdx].slice(); + handleNdx = _handleNdx; + window.addEventListener('pointermove', onMove, {passive: false}); + window.addEventListener('pointerup', onUp); + onMove(e); + } + + function updatePoints() { + device.queue.writeBuffer(vertexBuffer, 0, new Float32Array(points.flat())); + const ps = points.map((p, i) => { + const {group, text} = triPoints[i]; + const pp = clipSpaceToSVG(...p); + group.transform({translateX: pp[0], translateY: pp[1]}); + text + .text(p.map(v => v.toFixed(2)).join(',')) + .transform({ + translateX: Math.max(70 - pp[0], 0) + Math.min(svgWidth - 70 - pp[0], 0), + translateY: -20 + Math.max(40 - pp[1], 0), + }); + return `${i === 0 ? 'M' : 'L'}${clipSpaceToSVGPathPart(...p)}`; + }).join(' '); + triangle.plot(`${ps} Z`); + } + + //const t2 = v => v.toFixed(2).padStart(5); + + function render() { + updatePoints(); + //info.textContent = points.map((p, i) => `p${i}: ${p.map(v => t2(v)).join(', ')}`).join('\n'); + + renderPassDescriptor.colorAttachments[0].view = + context.getCurrentTexture().createView(); + + const encoder = device.createCommandEncoder({ + label: 'render quad encoder', + }); + const pass = encoder.beginRenderPass(renderPassDescriptor); + pass.setPipeline(pipeline); + pass.setVertexBuffer(0, vertexBuffer); + pass.draw(3); + pass.end(); + + const commandBuffer = encoder.finish(); + device.queue.submit([commandBuffer]); + } + render(); +} + +renderDiagrams({ + 'clip-space-to-texels': (elem) => { + const webgpuCanvas = el('canvas', {style: { display: 'block' }, className: 'fill-container nearest-neighbor-like'}); + const infoCanvas = el('div', {style: { display: 'block' }, className: 'fill-container align-top-left'}); + const diagramDiv = el('div', { style: { position: 'relative' } }, [ + webgpuCanvas, + infoCanvas, + ]); + + showClipSpaceToTexels({infoCanvas, webgpuCanvas}); + + elem.appendChild(diagramDiv); + }, +}); diff --git a/webgpu/lessons/webgpu-fundamentals.md b/webgpu/lessons/webgpu-fundamentals.md index fccf48ef..49c1c360 100644 --- a/webgpu/lessons/webgpu-fundamentals.md +++ b/webgpu/lessons/webgpu-fundamentals.md @@ -536,6 +536,19 @@ like `setPipeline`, and `draw` only add commands to a command buffer. They don't actually execute the commands. The commands are executed when we submit the command buffer to the device queue. +WebGPU takes every 3 vertices we return from our vertex shader uses +them to rasterize a triangle. It does this by determining which pixels' +centers are inside the triangle. It then calls our fragment shader for +each pixel to ask what color to make it. + +Imagine the texture we are rendering +to was 15x11 pixels. These are the pixels that would be drawn to + +
+
+
drag the vertices
+
+ So, now we've seen a very small working WebGPU example. It should be pretty obvious that hard coding a triangle inside a shader is not very flexible. We need ways to provide data and we'll cover those in the following articles. The @@ -1010,6 +1023,8 @@ when the lost promise resolves.

+ + diff --git a/webgpu/lessons/webgpu-rasterization.md b/webgpu/lessons/webgpu-rasterization.md new file mode 100644 index 00000000..3a616298 --- /dev/null +++ b/webgpu/lessons/webgpu-rasterization.md @@ -0,0 +1,21 @@ +Title: WebGPU Rasterization +Description: How WebGPU draws things +TOC: Rasterization + +This article provides some details into how WebGPU draws +points, lines, and triangles. Render pipelines generally +render points, lines, or triangles. Compute pipelines +can update textures or buffers directly, effectively doing +"software rasterization". This article is about the former, +sometimes called "hardware rasterization". + + + +
+
+
+ + + + +