Public
Edited
Jun 6, 2024
Insert cell
Insert cell
{
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const context = util.ctx(512, 512);
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
//

const module = device.createShaderModule({
code: `
struct Vertex {
@location(0) position: vec2f,
@location(1) color: vec4f,
@location(2) offset: vec2f,
@location(3) scale: vec2f,
@location(4) perVertexColor: vec3f,
};

struct VertexOut {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};

@vertex
fn vs(vertex: Vertex) -> VertexOut {
var vsOut: VertexOut;
vsOut.position = vec4f(vertex.position * vertex.scale + vertex.offset, 0.0, 1.0);
vsOut.color = vertex.color * vec4(vertex.perVertexColor, 1);
return vsOut;
}

@fragment
fn fs(vsOut: VertexOut) -> @location(0) vec4f {
return vsOut.color;
}
`
})

const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module,
entryPoint: 'vs',
buffers: [
{
arrayStride: 2 * 4 + 4, // (2 floats * 4 bytes each) + 4 bytes
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x2' }, // position
// from float32x4 (float) -> unorm8x4 (uint) to save space.
// using 8bit values and telling WebGPU they should be normalized from 0 ↔ 255 to 0.0 ↔ 1.0
// no 8bit 3-value format, so use `unorm8x4` instead
{ shaderLocation: 4, offset: 8, format: 'unorm8x4' }, // perVertexColor
],
},
// static
{
arrayStride: 4 + 2 * 4, // 4 bytes + (2 floats * 4 bytes each)
// attr advances one per instance (vs one per vert, starting over at instance)
stepMode: 'instance',
attributes: [
{ shaderLocation: 1, offset: 0, format: 'unorm8x4' }, // color (rgba -> 4 bytes)
{ shaderLocation: 2, offset: 0 + 4, format: 'float32x2' }, // offset (xy -> 2x4)
],
},
// dynamic
{
arrayStride: 2 * 4, // 2 floats * 4 bytes each
stepMode: 'instance',
attributes: [
{ shaderLocation: 3, offset: 0, format: 'float32x2' }, // scale (xy)
]
}
]
},
fragment: {
module,
entryPoint: 'fs',
targets: [{ format }]
}
})

// janky but we're not focused on perf rn...
const xExtent = [Math.min(...data.map(d => d.len)), Math.max(...data.map(d => d.len))];
const yExtent = [Math.min(...data.map(d => d.depth)), Math.max(...data.map(d => d.depth))];
const kNumObjects = data.length;
const dynamicData = [];
// create 2 storage buffers
const staticUnitSize =
4 + // color = 4 bytes
2 * 4; // offset = 2x4 (2 32-bit-floats x 4 bytes)
const dynamicUnitSize =
2 * 4; // scale = 2x4 (2 32-bit-floats x 4 bytes)
const staticBufferSize = staticUnitSize * kNumObjects;
const dynamicBufferSize = dynamicUnitSize * kNumObjects;

const staticBuffer = device.createBuffer({
size: staticBufferSize,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

const dynamicBuffer = device.createBuffer({
size: dynamicBufferSize,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
// offsets to the various uniform values in float32 indices
const kColorOffset = 0;
const kOffsetOffset = 1; // offset changes from 4 bytes to 1 byte
//
const kScaleOffset = 0;

const staticValuesU8 = new Uint8Array(staticBufferSize);
const staticValuesF32 = new Float32Array(staticValuesU8.buffer);
for (let i = 0; i < kNumObjects; i++) {
const d = data[i];
const c = species.indexOf(data[i].species);

const staticOffsetU8 = i * staticUnitSize;
const staticOffsetF32 = staticOffsetU8 / 4;

// color
staticValuesU8.set(
[+(c === 0) * 255, +(c === 1) * 255, +(c === 2) * 255, 255],
staticOffsetU8 + kColorOffset
);

// offset
staticValuesF32.set(
// lerp to clip space
// in subsequent notebooks, we will do these calculations in shaders
[
-1 + ((d.len - xExtent[0]) / (xExtent[1] - xExtent[0])) * (1 - (-1)),
-1 + ((d.depth - yExtent[0]) / (yExtent[1] - yExtent[0])) * (1 - (-1))
],
staticOffsetF32 + kOffsetOffset
);
// scale
dynamicData.push({ scale: 0.025 });
}
// write to gpu; will write dynamic values during render
device.queue.writeBuffer(staticBuffer, 0, staticValuesF32);

// used to update dynamicBuffer
const dynamicValues = new Float32Array(dynamicBufferSize / 4);
// position (`@location(0)`)
const { vertices, indices, count: vertexCount } = util.geom.circle({ r: 1, innerR: 0 });
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexBuffer, 0, vertices);

// optimization!
// create an index buffer (see util.geom.circle to see the logic behind this)
const indexBuffer = device.createBuffer({
size: indices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(indexBuffer, 0, indices);
const renderPassDescriptor = {
colorAttachments: [
{
clearValue: [0, 0, 0, 1],
loadOp: 'clear',
storeOp: 'store',
view: undefined, // to be filled out when we render
},
],
};

function render() {
renderPassDescriptor.colorAttachments[0].view = context.getCurrentTexture().createView();

const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setVertexBuffer(1, staticBuffer);
pass.setVertexBuffer(2, dynamicBuffer);

// optimization! index buffer for deduped triangle vertices
pass.setIndexBuffer(indexBuffer, 'uint32');
// can cache this w a flag, esp since getting canvas dims is expensive
const aspect = context.canvas.width / context.canvas.height;

// set scales for each object
dynamicData.forEach(({ scale }, i) => {
const offset = i * (dynamicUnitSize / 4);
dynamicValues.set([scale / aspect, scale], offset + kScaleOffset); // set scale
});
// upload all scales at once
device.queue.writeBuffer(dynamicBuffer, 0, dynamicValues);

// pass.draw(vertexCount, kNumObjects);
// optimization!
pass.drawIndexed(vertexCount, kNumObjects);
pass.end();
device.queue.submit([encoder.finish()]);
}

render()
return htl.html`
<figure>
${context.canvas}
<figcaption>
x = penguin culmen length (mm); y = penguin culment depth (mm)
<br><br>
${legend}
<br>
</figcaption>
</figure>
`;
}
Insert cell
Insert cell
data = penguins.reduce((ps, p) =>
isNaN(p.culmen_length_mm) || isNaN(p.culmen_depth_mm)
? ps
: [...ps, { len: p.culmen_length_mm, depth: p.culmen_depth_mm, species: p.species }],
[]
)
Insert cell
Insert cell
Insert cell
util = ({
rand: (min = 0, max = 1) => min + Math.random() * (max - min),
arr: (size, callback) => {
const arr = Array(size).fill(null)
return callback ? arr.map(callback) : arr;
},
ctx: (width = 512, height = 512) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('webgpu');
return context;
},
geom: {
// circle optimized with nonrepeating vertices & index buffers
circle: ({
r = 1,
subdivisions = 16,
innerR = 0,
startAngle = 0,
endAngle = Math.PI * 2
}) => {
// 2 vertices at each subdivision, + 1 to wrap around the circle
const numVerts = (subdivisions + 1) * 2;
// ... * (2 32-bit vals for xy position and 1 32-bit val for rgb_ color)
const vertices = new Float32Array(numVerts * (2 + 1));
// 32-bit color val will be read/written as 4 8-bit vals
const colors = new Uint8Array(vertices.buffer);

let offset = 0;
let colorOffset = 8;
const add = (x, y, r, g, b) => {
vertices[offset++] = x;
vertices[offset++] = y;
offset += 1; // skip the color; vertices = [x, y, color, ...]
colors[colorOffset++] = r * 255;
colors[colorOffset++] = g * 255;
colors[colorOffset++] = b * 255;
colorOffset += (1 + 8); // skip extra byte (unused alpha) + xy position (2 * 4)
};

// will multiply actual colors by these values in shader
const innerColor = [1, 1, 1];
const outerColor = [0.1, 0.1, 0.1];

// 2 triangles per subdivision
//
// previously we created this per subdivision:
// 0--1 4...
// | / /|
// |/ / |
// 2 3--5...
//
// however, we want to form this structure with deduped vertices:
// 0---2---4---...
// | //| //|
// |// |// |//
// 1---3-- 5---...
//
// so on each iteration here, we create the following (not actually connected yet tho):
//
// iter 1: 0 iter 2: 2 iter 3: 4
// | | | and so on...
// | | |
// 1 3 5
//
// ... whose indices we then connect in the subsequent loop
for (let i = 0; i <= subdivisions; i++) {
const angle = startAngle + (i + 0) * (endAngle - startAngle) / subdivisions;
const c1 = Math.cos(angle);
const s1 = Math.sin(angle);
add(c1 * r, s1 * r, ...outerColor); // 0, 2, 4, 6...
add(c1 * innerR, s1 * innerR, ...innerColor); // 1, 3, 5, 7...
}

// create triangles!
// 3 verts per tri * 2 tris
const indices = new Uint32Array(subdivisions * 6);
let idx = 0;
for (let i = 0; i < subdivisions; i++) {
const indexOffset = i * 2;

// below indices form this:
// 0---2
// | //|
// |// |
// 1---3
//
// first tri
// 0--2
// | /
// |/
// 1
indices[idx++] = indexOffset + 0;
indices[idx++] = indexOffset + 1;
indices[idx++] = indexOffset + 2;
// second tri
// 2
// /|
// / |
// 1--3
indices[idx++] = indexOffset + 2;
indices[idx++] = indexOffset + 1;
indices[idx++] = indexOffset + 3;
}
return { vertices, indices, count: indices.length }
},
//
//
// old logic with duped verts
//
//
circle_UNOPTIMIZED: ({
r = 1,
subdivisions = 16,
innerR = 0,
startAngle = 0,
endAngle = Math.PI * 2
}) => {
// 2 triangles per subdivision * 3 verts per tri
const numVerts = subdivisions * 2 * 3;
// ... * (2 32-bit vals for xy position and 1 32-bit val for rgb_ color)
const vertices = new Float32Array(numVerts * (2 + 1));
// 32-bit color val will be read/written as 4 8-bit vals
const colors = new Uint8Array(vertices.buffer);

// 2 vertices per subdivision
//
// outer
// 0--1 4
// | / /|
// |/ / |
// 2 3--5
// inner
let offset = 0;
let colorOffset = 8;
const add = (x, y, r, g, b) => {
vertices[offset++] = x;
vertices[offset++] = y;
offset += 1; // skip the color; vertices = [x, y, color, ...]
colors[colorOffset++] = r * 255;
colors[colorOffset++] = g * 255;
colors[colorOffset++] = b * 255;
colorOffset += (1 + 8); // skip extra byte (unused alpha) + xy position (2 * 4)
};

// will multiply actual colors by these values in shader
const innerColor = [1, 1, 1];
const outerColor = [0.1, 0.1, 0.1];
for (let i = 0; i < subdivisions; i++) {
// can do lots of perf improvements here
const angle1 = startAngle + (i + 0) * (endAngle - startAngle) / subdivisions;
const angle2 = startAngle + (i + 1) * (endAngle - startAngle) / subdivisions;
const c1 = Math.cos(angle1);
const s1 = Math.sin(angle1);
const c2 = Math.cos(angle2);
const s2 = Math.sin(angle2);

// first tri
add(c1 * r, s1 * r, ...outerColor)
add(c2 * r, s2 * r, ...outerColor)
add(c1 * innerR, s1 * innerR, ...innerColor)
// second tri
add(c1 * innerR, s1 * innerR, ...innerColor)
add(c2 * r, s2 * r, ...outerColor)
add(c2 * innerR, s2 * innerR, ...innerColor)
}
return { vertices, count: numVerts }
}
}
})
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more