Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>WebGPU Text Rendering</title> | |
</head> | |
<body> | |
<canvas></canvas> | |
<script type="module"> | |
// WebGPU Simple Textured Quad - Import Canvas | |
// from https://webgpufundamentals.org/webgpu/webgpu-simple-textured-quad-import-canvas.html | |
import { mat4 } from 'https://webgpufundamentals.org/3rdparty/wgpu-matrix.module.js'; | |
const glyphWidth = 32; | |
const glyphHeight = 40; | |
const glyphsAcrossTexture = 16; | |
function genreateGlyphTextureAtlas() { | |
const canvas = document.createElement('canvas'); | |
canvas.width = 512; | |
canvas.height = 256; | |
const ctx = canvas.getContext('2d'); | |
ctx.font = '32px monospace'; | |
ctx.textBaseline = 'middle'; | |
ctx.textAlign = 'center'; | |
ctx.fillStyle = 'white'; | |
for (let c = 33, x = 0, y = 0; c < 128; ++c) { | |
ctx.fillText(String.fromCodePoint(c), x + glyphWidth / 2, y + glyphHeight / 2); | |
x = (x + glyphWidth) % canvas.width; | |
if (x === 0) y += glyphHeight; | |
} | |
return canvas; | |
} | |
async function main() { | |
const adapter = await navigator.gpu?.requestAdapter(); | |
const device = await adapter?.requestDevice(); | |
if (!device) { | |
fail('need a browser that supports WebGPU'); | |
return; | |
} | |
// Get a WebGPU context from the canvas and configure it | |
const canvas = document.querySelector('canvas'); | |
const context = canvas.getContext('webgpu'); | |
const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); | |
context.configure({ | |
device, | |
format: presentationFormat, | |
}); | |
const module = device.createShaderModule({ | |
label: 'our hardcoded textured quad shaders', | |
code: ` | |
struct VSInput { | |
@location(0) position: vec4f, | |
@location(1) texcoord: vec2f, | |
@location(2) color: vec4f, | |
}; | |
struct VSOutput { | |
@builtin(position) position: vec4f, | |
@location(0) texcoord: vec2f, | |
@location(1) color: vec4f, | |
}; | |
struct Uniforms { | |
matrix: mat4x4f, | |
}; | |
@group(0) @binding(2) var<uniform> uni: Uniforms; | |
@vertex fn vs(vin: VSInput) -> VSOutput { | |
var vsOutput: VSOutput; | |
vsOutput.position = uni.matrix * vin.position; | |
vsOutput.texcoord = vin.texcoord; | |
vsOutput.color = vin.color; | |
return vsOutput; | |
} | |
@group(0) @binding(0) var ourSampler: sampler; | |
@group(0) @binding(1) var ourTexture: texture_2d<f32>; | |
@fragment fn fs(fsInput: VSOutput) -> @location(0) vec4f { | |
return textureSample(ourTexture, ourSampler, fsInput.texcoord) * fsInput.color; | |
} | |
`, | |
}); | |
const glyphCanvas = genreateGlyphTextureAtlas(); | |
// so we can see it | |
document.body.appendChild(glyphCanvas); | |
glyphCanvas.style.backgroundColor = '#222'; | |
const maxGlyphs = 100; | |
const floatsPerVertex = 2 + 2 + 4; // 2(pos) + 2(texcoord) + 4(color) | |
const vertexSize = floatsPerVertex * 4; // 4 bytes each float | |
const vertsPerGlyph = 6; | |
const vertexBufferSize = maxGlyphs * vertsPerGlyph * vertexSize; | |
const vertexBuffer = device.createBuffer({ | |
label: 'vertices', | |
size: vertexBufferSize, | |
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, | |
}); | |
const indexBuffer = device.createBuffer({ | |
label: 'indices', | |
size: maxGlyphs * vertsPerGlyph * 4, | |
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, | |
}); | |
// pre fill index buffer with quad indices | |
{ | |
const indices = []; | |
for (let i = 0; i < maxGlyphs; ++i) { | |
const ndx = i * 4; | |
indices.push(ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3); | |
} | |
device.queue.writeBuffer(indexBuffer, 0, new Uint32Array(indices)); | |
} | |
function generateGlyphVerticesForText(s, colors = [[1, 1, 1, 1]]) { | |
const vertexData = new Float32Array(maxGlyphs * floatsPerVertex * vertsPerGlyph); | |
const glyphUVWidth = glyphWidth / glyphCanvas.width; | |
const glyphUVHeight = glyphHeight / glyphCanvas.height; | |
let offset = 0, x0 = 0, y0 = 0, x1 = 1, y1 = 1, width = 0; | |
let colorNdx = 0; | |
const addVertex = (x, y, u, v, r, g, b, a) => { | |
vertexData.set([x, y, u, v, r, g, b, a], offset); | |
offset += 8; | |
}; | |
for (let i = 0; i < s.length; ++i) { | |
const c = s.charCodeAt(i); | |
if (c >= 33) { | |
const glyphX = (c - 33) % glyphsAcrossTexture; | |
const glyphY = Math.floor((c - 33) / glyphsAcrossTexture); | |
const u0 = (glyphX * glyphWidth) / glyphCanvas.width; | |
const v0 = ((glyphY + 1) * glyphHeight) / glyphCanvas.height; | |
const u1 = u0 + glyphUVWidth; | |
const v1 = v0 - glyphUVHeight; | |
width = Math.max(x1, width); | |
addVertex(x0, y0, u0, v0, ...colors[colorNdx]); | |
addVertex(x1, y0, u1, v0, ...colors[colorNdx]); | |
addVertex(x0, y1, u0, v1, ...colors[colorNdx]); | |
addVertex(x1, y1, u1, v1, ...colors[colorNdx]); | |
} else { | |
colorNdx = (colorNdx + 1) % colors.length; | |
if (c === 10) { | |
x0 = 0; x1 = 1; y0--; y1 = y0 + 1; | |
continue; | |
} | |
} | |
x0 += 0.55; x1 = x0 + 1; | |
} | |
return { vertexData, numGlyphs: offset / floatsPerVertex, width, height: y1 }; | |
} | |
const { vertexData, numGlyphs, width, height } = generateGlyphVerticesForText( | |
'Hello\nworld!\nText in\nWebGPU!', [ | |
[1, 1, 0, 1], [0, 1, 1, 1], [1, 0, 1, 1], [1, 0, 0, 1], [0, .5, 1, 1], | |
]); | |
device.queue.writeBuffer(vertexBuffer, 0, vertexData); | |
const pipeline = device.createRenderPipeline({ | |
label: 'textured quad pipeline', | |
layout: 'auto', | |
vertex: { | |
module, | |
entryPoint: 'vs', | |
buffers: [ | |
{ | |
arrayStride: vertexSize, | |
attributes: [ | |
{ shaderLocation: 0, offset: 0, format: 'float32x2' }, // position | |
{ shaderLocation: 1, offset: 8, format: 'float32x2' }, // texcoord | |
{ shaderLocation: 2, offset: 16, format: 'float32x4' } // color | |
], | |
}, | |
], | |
}, | |
fragment: { | |
module, | |
entryPoint: 'fs', | |
targets: [{ format: presentationFormat }], | |
}, | |
}); | |
function copySourceToTexture(device, texture, source, { flipY } = {}) { | |
device.queue.copyExternalImageToTexture( | |
{ source, flipY, }, | |
{ texture, premultipliedAlpha: true }, | |
{ width: source.width, height: source.height }, | |
); | |
} | |
function createTextureFromSource(device, source, options = {}) { | |
const texture = device.createTexture({ | |
format: 'rgba8unorm', | |
size: [source.width, source.height], | |
usage: GPUTextureUsage.TEXTURE_BINDING | | |
GPUTextureUsage.COPY_DST | | |
GPUTextureUsage.RENDER_ATTACHMENT, | |
}); | |
copySourceToTexture(device, texture, source, options); | |
return texture; | |
} | |
const texture = createTextureFromSource(device, glyphCanvas, { mips: true }); | |
const sampler = device.createSampler({ | |
minFilter: 'linear', | |
magFilter: 'linear', | |
}); | |
// create a buffer for the uniform values | |
const uniformBufferSize = | |
16 * 4; // matrix is 16 32bit floats (4bytes each) | |
const uniformBuffer = device.createBuffer({ | |
label: 'uniforms for quad', | |
size: uniformBufferSize, | |
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, | |
}); | |
// create a typedarray to hold the values for the uniforms in JavaScript | |
const kMatrixOffset = 0; | |
const uniformValues = new Float32Array(uniformBufferSize / 4); | |
const matrix = uniformValues.subarray(kMatrixOffset, 16); | |
const bindGroup = device.createBindGroup({ | |
layout: pipeline.getBindGroupLayout(0), | |
entries: [ | |
{ binding: 0, resource: sampler }, | |
{ binding: 1, resource: texture.createView() }, | |
{ binding: 2, resource: { buffer: uniformBuffer } }, | |
], | |
}); | |
const renderPassDescriptor = { | |
label: 'our basic canvas renderPass', | |
colorAttachments: [ | |
{ | |
// view: <- to be filled out when we render | |
clearValue: [0.3, 0.3, 0.3, 1], | |
loadOp: 'clear', | |
storeOp: 'store', | |
}, | |
], | |
}; | |
function render(time) { | |
time *= 0.001; | |
const fov = 60 * Math.PI / 180; // 60 degrees in radians | |
const aspect = canvas.clientWidth / canvas.clientHeight; | |
const zNear = 0.001; | |
const zFar = 50; | |
const projectionMatrix = mat4.perspective(fov, aspect, zNear, zFar); | |
const cameraPosition = [0, 0, 5]; | |
const up = [0, 1, 0]; | |
const target = [0, 0, 0]; | |
const viewMatrix = mat4.lookAt(cameraPosition, target, up); | |
const viewProjectionMatrix = mat4.multiply(projectionMatrix, viewMatrix); | |
// Get the current texture from the canvas context and | |
// set it as the texture to render to. | |
renderPassDescriptor.colorAttachments[0].view = | |
context.getCurrentTexture().createView(); | |
const encoder = device.createCommandEncoder({ | |
label: 'render quad encoder', | |
}); | |
const pass = encoder.beginRenderPass(renderPassDescriptor); | |
pass.setPipeline(pipeline); | |
mat4.rotateY(viewProjectionMatrix, time, matrix); | |
mat4.translate(matrix, [-width / 2, -height / 2, 0], matrix); | |
// copy the values from JavaScript to the GPU | |
device.queue.writeBuffer(uniformBuffer, 0, uniformValues); | |
pass.setBindGroup(0, bindGroup); | |
pass.setVertexBuffer(0, vertexBuffer); | |
pass.setIndexBuffer(indexBuffer, 'uint32'); | |
pass.drawIndexed(numGlyphs * 6); | |
pass.end(); | |
const commandBuffer = encoder.finish(); | |
device.queue.submit([commandBuffer]); | |
requestAnimationFrame(render); | |
} | |
requestAnimationFrame(render); | |
} | |
function fail(msg) { | |
// eslint-disable-next-line no-alert | |
alert(msg); | |
} | |
main(); | |
</script> | |
</body> | |
</html> |