Last active
February 21, 2024 17:29
-
-
Save greggman/57c3c0d3cf1db14f1baad2d9b0094397 to your computer and use it in GitHub Desktop.
WebGPU - Picking - GPU (vs SVG hover)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
html, body { margin: 0; height: 100%; font-family: monospace; } | |
svg, canvas { width: 100%; height: 100%; display: block; } | |
svg { | |
position: absolute; | |
left: 0; | |
top: 0; | |
} | |
#info { | |
position: absolute; | |
right: 0; | |
top: 0; | |
padding: 0.5em; | |
background-color: rgba(0, 0, 0, 0.9); | |
color: white; | |
min-width: 7em; | |
} | |
#fail { | |
position: fixed; | |
left: 0; | |
top: 0; | |
width: 100%; | |
height: 100%; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
background: red; | |
color: white; | |
font-weight: bold; | |
font-family: monospace; | |
font-size: 16pt; | |
text-align: center; | |
} | |
.shape:hover { | |
fill: red; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<canvas></canvas> | |
<svg></svg> | |
<div id="info"> | |
<label><input type=checkbox id="move">animate</label> | |
<div id="pick"><div> | |
</div> | |
<div id="fail" style="display: none"> | |
<div class="content"></div> | |
</div> | |
<script src="https://mrdoob.github.io/stats.js/build/stats.min.js"></script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// WebGPU Cube | |
// from http://localhost:8080/webgpu/webgpu-cube.html | |
/* global GPUBufferUsage */ | |
/* global GPUTextureUsage */ | |
import {vec3, mat4} from 'https://webgpufundamentals.org/3rdparty/wgpu-matrix.module.js'; | |
import * as twgl from 'https://twgljs.org/dist/4.x/twgl-full.module.js'; | |
const degToRad = d => d * Math.PI / 180; | |
const rand = (min, max) => Math.random() * (max - min) + min; | |
async function main() { | |
const gpu = navigator.gpu; | |
if (!gpu) { | |
fail('this browser does not support webgpu'); | |
return; | |
} | |
const adapter = await gpu.requestAdapter(); | |
if (!adapter) { | |
fail('this browser appears to support WebGPU but it\'s disabled'); | |
return; | |
} | |
const device = await adapter.requestDevice(); | |
const canvas = document.querySelector('canvas'); | |
const svg = document.querySelector('svg'); | |
const context = canvas.getContext('webgpu'); | |
const numCircles = 1000; | |
const svgns = "http://www.w3.org/2000/svg"; | |
for (let i = 0; i < numCircles; ++i) { | |
const circle = document.createElementNS(svgns, 'circle'); | |
circle.setAttribute('cx', rand(0, 800) | 0); | |
circle.setAttribute('cy', rand(0, 800) | 0); | |
circle.setAttribute('r', 20); | |
circle.setAttribute('class', 'shape'); | |
svg.appendChild(circle); | |
} | |
const presentationFormat = navigator.gpu.getPreferredCanvasFormat(adapter); | |
const presentationSize = [300, 150]; // default canvas size | |
const canvasInfo = { | |
canvas, | |
context, | |
presentationSize, | |
presentationFormat, | |
// these are filled out in resizeToDisplaySize | |
renderTarget: undefined, | |
renderTargetView: undefined, | |
depthTexture: undefined, | |
depthTextureView: undefined, | |
sampleCount: 4, // can be 1 or 4 | |
}; | |
const shaderSrc = ` | |
struct VSUniforms { | |
worldViewProjection: mat4x4f, | |
}; | |
@group(0) @binding(0) var<uniform> vsUniforms: VSUniforms; | |
struct MyVSInput { | |
@location(0) position: vec4f, | |
@location(1) normal: vec3f, | |
}; | |
struct MyVSOutput { | |
@builtin(position) position: vec4f, | |
@location(0) color: vec4f, | |
}; | |
@vertex | |
fn myVSMain(v: MyVSInput) -> MyVSOutput { | |
var vsOut: MyVSOutput; | |
vsOut.position = vsUniforms.worldViewProjection * v.position; | |
vsOut.color = vec4f(v.normal * 0.5 + 0.5, 1.0) * 0.0 + 1.0; | |
return vsOut; | |
} | |
struct FSUniforms { | |
colorMult: vec4f, | |
}; | |
struct PickUniforms { | |
id: u32, | |
}; | |
@group(0) @binding(1) var<uniform> fsUniforms: FSUniforms; | |
@group(0) @binding(1) var<uniform> pickUniforms: PickUniforms; | |
@fragment | |
fn myFSMain(v: MyVSOutput) -> @location(0) vec4f { | |
return v.color * fsUniforms.colorMult; | |
} | |
@fragment | |
fn pickFSMain(v: MyVSOutput) -> @location(0) u32 { | |
return pickUniforms.id; | |
} | |
`; | |
const shaderModule = device.createShaderModule({code: shaderSrc}); | |
const pipeline = device.createRenderPipeline({ | |
vertex: { | |
module: shaderModule, | |
entryPoint: 'myVSMain', | |
buffers: [ | |
// position | |
{ | |
arrayStride: 3 * 4, // 3 floats, 4 bytes each | |
attributes: [ | |
{shaderLocation: 0, offset: 0, format: 'float32x3'}, | |
], | |
}, | |
// normals | |
{ | |
arrayStride: 3 * 4, // 3 floats, 4 bytes each | |
attributes: [ | |
{shaderLocation: 1, offset: 0, format: 'float32x3'}, | |
], | |
}, | |
], | |
}, | |
fragment: { | |
module: shaderModule, | |
entryPoint: 'myFSMain', | |
targets: [ | |
{format: presentationFormat}, | |
], | |
}, | |
layout: 'auto', | |
primitive: { | |
topology: 'triangle-list', | |
cullMode: 'back', | |
}, | |
depthStencil: { | |
depthWriteEnabled: true, | |
depthCompare: 'less', | |
format: 'depth24plus', | |
}, | |
...(canvasInfo.sampleCount > 1 && { | |
multisample: { | |
count: canvasInfo.sampleCount, | |
}, | |
}), | |
}); | |
const pickPipeline = device.createRenderPipeline({ | |
vertex: { | |
module: shaderModule, | |
entryPoint: 'myVSMain', | |
buffers: [ | |
// position | |
{ | |
arrayStride: 3 * 4, // 3 floats, 4 bytes each | |
attributes: [ | |
{shaderLocation: 0, offset: 0, format: 'float32x3'}, | |
], | |
}, | |
// normals | |
{ | |
arrayStride: 3 * 4, // 3 floats, 4 bytes each | |
attributes: [ | |
{shaderLocation: 1, offset: 0, format: 'float32x3'}, | |
], | |
}, | |
], | |
}, | |
fragment: { | |
module: shaderModule, | |
entryPoint: 'pickFSMain', | |
targets: [ | |
{format: 'r32uint'}, | |
], | |
}, | |
layout: 'auto', | |
primitive: { | |
topology: 'triangle-list', | |
cullMode: 'back', | |
}, | |
depthStencil: { | |
depthWriteEnabled: true, | |
depthCompare: 'less', | |
format: 'depth24plus', | |
}, | |
}); | |
function createBuffer(device, data, usage) { | |
const buffer = device.createBuffer({ | |
size: data.byteLength, | |
usage, | |
mappedAtCreation: true, | |
}); | |
const dst = new data.constructor(buffer.getMappedRange()); | |
dst.set(data); | |
buffer.unmap(); | |
return buffer; | |
} | |
function createGeo(device, vertices) { | |
const d = twgl.primitives.deindexVertices(vertices); | |
const f = twgl.primitives.flattenNormals(d); | |
return { | |
buffers: { | |
position: createBuffer(device, f.position, GPUBufferUsage.VERTEX), | |
normal: createBuffer(device, f.normal, GPUBufferUsage.VERTEX), | |
}, | |
numVerts: f.position.length / 3, | |
}; | |
} | |
function updateUniformBuffer(device, ub) { | |
device.queue.writeBuffer( | |
ub.uniformBuffer, | |
0, | |
ub.values.buffer, | |
ub.values.byteOffset, | |
ub.values.byteLength, | |
); | |
} | |
const sphereBufferInfo = createGeo(device, twgl.primitives.createSphereVertices(10, 24, 12)); | |
const shapes = [ | |
sphereBufferInfo, | |
]; | |
const objects = []; | |
const numObjects = numCircles; | |
for (let ii = 0; ii < numObjects; ++ii) { | |
const vUniformBufferSize = 1 * 16 * 4; // 1 mat4s * 16 floats per mat * 4 bytes per float | |
const fUniformBufferSize = 4 * 4; // 1 vec3 * 3 floats per vec3 * 4 bytes per float | |
const vsUniformBuffer = device.createBuffer({ | |
size: vUniformBufferSize, | |
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, | |
}); | |
const fsUniformBuffer = device.createBuffer({ | |
size: fUniformBufferSize, | |
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, | |
}); | |
const pickFSUniformBuffer = device.createBuffer({ | |
size: fUniformBufferSize, | |
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, | |
}); | |
const vsUniformValues = new Float32Array(1 * 16); // 1 mat4s | |
const worldViewProjection = vsUniformValues.subarray(0, 16); | |
const fsUniformValues = new Float32Array(4); // 1 vec4 | |
const colorMult = fsUniformValues.subarray(0, 4); | |
const pickFSUniformValues = new Uint32Array(1); // 1 uint32 | |
const id = pickFSUniformValues.subarray(0, 1); | |
colorMult.set([0, 0, 0, 1]); | |
id.set([ii + 1]); | |
const bindGroup = device.createBindGroup({ | |
layout: pipeline.getBindGroupLayout(0), | |
entries: [ | |
{ binding: 0, resource: { buffer: vsUniformBuffer } }, | |
{ binding: 1, resource: { buffer: fsUniformBuffer } }, | |
], | |
}); | |
const pickBindGroup = device.createBindGroup({ | |
layout: pickPipeline.getBindGroupLayout(0), | |
entries: [ | |
{ binding: 0, resource: { buffer: vsUniformBuffer } }, | |
{ binding: 1, resource: { buffer: pickFSUniformBuffer } }, | |
], | |
}); | |
const object = { | |
v: { | |
translation: [rand(-100, 100), rand(-100, 100), rand(-150, -50)], | |
xRotationSpeed: rand(0.8, 1.2), | |
yRotationSpeed: rand(0.8, 1.2), | |
velocity: [rand(-10, 10), rand(-10, 10)], | |
}, | |
vs: { | |
uniformBuffer: vsUniformBuffer, | |
values: vsUniformValues, | |
}, | |
fs: { | |
uniformBuffer: fsUniformBuffer, | |
values: fsUniformValues, | |
}, | |
pickFS: { | |
uniformBuffer: pickFSUniformBuffer, | |
values: pickFSUniformValues, | |
}, | |
uniforms: { | |
worldViewProjection, | |
colorMult, | |
id, | |
}, | |
bufferInfo: shapes[ii % shapes.length], | |
bindGroup, | |
pickBindGroup, | |
}; | |
updateUniformBuffer(device, object.fs); | |
updateUniformBuffer(device, object.pickFS); | |
objects.push(object); | |
} | |
function computeMatrix(viewProjectionMatrix, translation, xRotation, yRotation, dst) { | |
mat4.translate(viewProjectionMatrix, translation, dst); | |
mat4.rotateX(dst, xRotation, dst); | |
mat4.rotateY(dst, yRotation, dst); | |
mat4.scale(dst, [0.6, 0.6, 0.6], dst); | |
} | |
const renderPassDescriptor = { | |
colorAttachments: [ | |
{ | |
// view: undefined, // Assigned later | |
// resolveTarget: undefined, // Assigned Later | |
clearValue: { r: 1, g: 1, b: 1, a: 1 }, | |
loadOp: 'clear', | |
storeOp: 'store', | |
}, | |
], | |
depthStencilAttachment: { | |
// view: undefined, // Assigned later | |
depthClearValue: 1, | |
depthLoadOp: 'clear', | |
depthStoreOp: 'store', | |
}, | |
}; | |
function resizeToDisplaySize(device, canvasInfo) { | |
const { | |
canvas, | |
context, | |
renderTarget, | |
presentationSize, | |
presentationFormat, | |
depthTexture, | |
sampleCount, | |
} = canvasInfo; | |
const width = Math.min(device.limits.maxTextureDimension2D, canvas.clientWidth); | |
const height = Math.min(device.limits.maxTextureDimension2D, canvas.clientHeight); | |
const needResize = !canvasInfo.renderTarget || | |
width !== presentationSize[0] || | |
height !== presentationSize[1]; | |
if (needResize) { | |
if (renderTarget) { | |
renderTarget.destroy(); | |
} | |
if (depthTexture) { | |
depthTexture.destroy(); | |
} | |
const newSize = [width, height]; | |
canvas.width = width; | |
canvas.height = height; | |
context.configure({ | |
device, | |
format: presentationFormat, | |
compositingAlphaMode: "premultiplied", | |
}); | |
if (sampleCount > 1) { | |
const newRenderTarget = device.createTexture({ | |
size: newSize, | |
format: presentationFormat, | |
sampleCount, | |
usage: GPUTextureUsage.RENDER_ATTACHMENT, | |
}); | |
canvasInfo.renderTarget = newRenderTarget; | |
canvasInfo.renderTargetView = newRenderTarget.createView(); | |
} | |
const newDepthTexture = device.createTexture({ | |
size: newSize, | |
format: 'depth24plus', | |
sampleCount, | |
usage: GPUTextureUsage.RENDER_ATTACHMENT, | |
}); | |
canvasInfo.depthTexture = newDepthTexture; | |
canvasInfo.depthTextureView = newDepthTexture.createView(); | |
presentationSize[0] = width; | |
presentationSize[1] = height; | |
} | |
return needResize; | |
} | |
const fieldOfViewRadians = degToRad(60); | |
let mouseX = -1; | |
let mouseY = -1; | |
let oldPickNdx = -1; | |
let oldPickColor = new Float32Array(4); | |
let waitingForPreviousResults = false; | |
const pickRenderPassDescriptor = { | |
colorAttachments: [ | |
{ | |
// view: undefined, // Assigned later | |
// resolveTarget: undefined, // Assigned Later | |
clearValue: { r: 0, g: 0, b: 0, a: 0 }, | |
loadOp: 'clear', | |
storeOp: 'store', | |
}, | |
], | |
depthStencilAttachment: { | |
// view: undefined, // Assigned later | |
depthClearValue: 1, | |
depthLoadOp: 'clear', | |
depthStoreOp: 'store', | |
}, | |
}; | |
const numPixels = 1; | |
const pickBuffer = device.createBuffer({ | |
size: numPixels * 4, | |
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, | |
}); | |
let pickTexture; | |
let pickDepthTexture; | |
const pickTextureSize = []; | |
const infoElem = document.querySelector('#pick'); | |
function resizeTexture(tex, oldSize, newSize, format) { | |
if (!tex || oldSize[0] !== newSize[0] || oldSize[1] !== newSize[1]) { | |
if (tex) { | |
tex.destroy(); | |
} | |
tex = device.createTexture({ | |
size: [...newSize, 1], | |
format, | |
usage: | |
GPUTextureUsage.COPY_SRC | | |
GPUTextureUsage.RENDER_ATTACHMENT, | |
}); | |
} | |
return tex; | |
} | |
async function drawWithIdsForMaterialsAndGetPixelUnderMouse(pixelX, pixelY) { | |
if (pixelX < 0 || pixelY < 1 || pixelX >= presentationSize[0] || pixelY >= presentationSize[1]) { | |
return 0; | |
} | |
pickTexture = resizeTexture(pickTexture, pickTextureSize, presentationSize, 'r32uint'); | |
pickDepthTexture = resizeTexture(pickDepthTexture, pickTextureSize, presentationSize, 'depth24plus'); | |
pickRenderPassDescriptor.colorAttachments[0].view = pickTexture.createView(); | |
pickRenderPassDescriptor.depthStencilAttachment.view = pickDepthTexture.createView(); | |
const commandEncoder = device.createCommandEncoder(); | |
const passEncoder = commandEncoder.beginRenderPass(pickRenderPassDescriptor); | |
passEncoder.setPipeline(pickPipeline); | |
for (const object of objects) { | |
passEncoder.setBindGroup(0, object.pickBindGroup); | |
passEncoder.setVertexBuffer(0, object.bufferInfo.buffers.position); | |
passEncoder.setVertexBuffer(1, object.bufferInfo.buffers.normal); | |
passEncoder.draw(object.bufferInfo.numVerts); | |
} | |
passEncoder.end(); | |
commandEncoder.copyTextureToBuffer({ | |
texture: pickTexture, | |
// mipLevel: 0, | |
origin: { | |
x: pixelX, | |
y: pixelY, | |
} | |
}, { | |
buffer: pickBuffer, | |
bytesPerRow: ((numPixels * 4 + 255) | 0) * 256, | |
rowsPerImage: 1, | |
}, { | |
width: numPixels, | |
// height: 1, | |
// depth: 1, | |
}); | |
device.queue.submit([commandEncoder.finish()]); | |
await pickBuffer.mapAsync(GPUMapMode.READ, 0, 4 * numPixels); | |
const ids = new Uint32Array(pickBuffer.getMappedRange(0, 4 * numPixels)); | |
const id = ids[0]; | |
pickBuffer.unmap(); | |
return id; | |
} | |
async function processPicking() { | |
if (!waitingForPreviousResults) { | |
waitingForPreviousResults = true; | |
const pixelX = mouseX * presentationSize[0] / canvas.clientWidth; | |
const pixelY = mouseY * presentationSize[1] / canvas.clientHeight; | |
const id = await drawWithIdsForMaterialsAndGetPixelUnderMouse(pixelX, pixelY); | |
if (oldPickNdx >= 0) { | |
const object = objects[oldPickNdx]; | |
object.uniforms.colorMult.set(oldPickColor); | |
updateUniformBuffer(device, object.fs); | |
oldPickNdx = -1; | |
} | |
if (id > objects.length) { | |
console.error(`id > numObject: ${id}`); | |
} else if (id > 0) { | |
const pickNdx = id - 1; | |
oldPickNdx = pickNdx; | |
const object = objects[pickNdx]; | |
oldPickColor.set(object.uniforms.colorMult); | |
object.uniforms.colorMult.set([1, 0, 0, 1]); | |
updateUniformBuffer(device, object.fs); | |
} | |
infoElem.textContent = `obj#: ${id ? id : 'none'}`; | |
waitingForPreviousResults = false; | |
} | |
} | |
let requestId; | |
function requestRender() { | |
if (!requestId) { | |
requestId = requestAnimationFrame(render); | |
} | |
} | |
svg.addEventListener('mousemove', (e) => { | |
const rect = canvas.getBoundingClientRect(); | |
mouseX = e.clientX - rect.left; | |
mouseY = e.clientY - rect.top; | |
}); | |
const stats = new Stats(); | |
document.body.appendChild(stats.dom); | |
const moveElem = document.querySelector('#move'); | |
let then = 0; | |
function render(time) { | |
time *= 0.001; | |
stats.begin(); | |
const deltaTime = time - then; | |
then = time; | |
requestId = undefined; | |
resizeToDisplaySize(device, canvasInfo); | |
const aspect = canvas.clientWidth / canvas.clientHeight; | |
const s = 100; | |
const projection = mat4.ortho(-aspect * s, aspect * s, -s, s, 1, 2000); | |
const w = s * aspect + 20; | |
const eye = [0, 0, 100]; | |
const target = [0, 0, 0]; | |
const up = [0, 1, 0]; | |
const view = mat4.lookAt(eye, target, up); | |
const viewProjection = mat4.multiply(projection, view); | |
const move = moveElem.checked; | |
// Compute the matrices for each object. | |
for (const object of objects) { | |
const { translation, velocity } = object.v; | |
if (move) { | |
translation[0] = (translation[0] + w + velocity[0] * deltaTime) % (w * 2) - w; | |
translation[1] = (translation[1] + s + velocity[1] * deltaTime) % (s * 2) - s; | |
} | |
computeMatrix( | |
viewProjection, | |
object.v.translation, | |
object.v.xRotationSpeed * time * 0, | |
object.v.yRotationSpeed * time * 0, | |
object.uniforms.worldViewProjection); | |
updateUniformBuffer(device, object.vs); | |
} | |
if (canvasInfo.sampleCount === 1) { | |
const colorTexture = context.getCurrentTexture(); | |
renderPassDescriptor.colorAttachments[0].view = colorTexture.createView(); | |
} else { | |
renderPassDescriptor.colorAttachments[0].view = canvasInfo.renderTargetView; | |
renderPassDescriptor.colorAttachments[0].resolveTarget = context.getCurrentTexture().createView(); | |
} | |
renderPassDescriptor.depthStencilAttachment.view = canvasInfo.depthTextureView; | |
const commandEncoder = device.createCommandEncoder(); | |
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); | |
passEncoder.setPipeline(pipeline); | |
for (const object of objects) { | |
passEncoder.setBindGroup(0, object.bindGroup); | |
passEncoder.setVertexBuffer(0, object.bufferInfo.buffers.position); | |
passEncoder.setVertexBuffer(1, object.bufferInfo.buffers.normal); | |
passEncoder.draw(object.bufferInfo.numVerts); | |
} | |
passEncoder.end(); | |
device.queue.submit([commandEncoder.finish()]); | |
processPicking(); | |
requestRender(); | |
stats.end(); | |
} | |
requestRender(); | |
} | |
function fail(msg) { | |
const elem = document.querySelector('#fail'); | |
const contentElem = elem.querySelector('.content'); | |
elem.style.display = ''; | |
contentElem.textContent = msg; | |
} | |
main(); | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{"name":"WebGPU - Picking - GPU (vs SVG hover)","settings":{},"filenames":["index.html","index.css","index.js"]} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment