Public
Edited
Mar 7
4 stars
Insert cell
Insert cell
Insert cell
Insert cell
{
const defaultColor = [0, 0, 0];
const rgbFromStr = (str /*: string */) => {
return str
?.match(/rgb\((\d+),\s?(\d+),\s?(\d+)\)/)
?.slice(1, 4)
?.map(x => +x / 255) ?? defaultColor;
};
const getHeight = feature => feature.properties.max_ft - feature.properties.min_ft
const [minHeight, maxHeight] = d3.extent(tile.features, f => getHeight(f))
const zScale = d3.scaleLinear().domain([0, maxHeight]).range([0, 1]);

const breakSize = 20
async function initLineRenderer({ context, device }) {
const margin = 0;
const projection = d3.geoMercator().fitExtent(
[
[margin, margin],
[TILE_WIDTH - margin, TILE_HEIGHT - margin],
],
tile,
);
const path = feature => {
const height = getHeight(feature)
switch (feature.geometry.type) {
case 'LineString': {
return feature.geometry.coordinates.reduce(
(vertices /*: number[][] */, [lng, lat] /*: [number, number] */) => {
const [y, x] = projection([lng, lat]) /* as [number, number] */;
vertices.push([x, y, zScale(height), height]);
return vertices;
},
[],
);
}
case 'Polygon': {
const maxRing = feature.geometry.coordinates.reduce(
(max, ring) => !max || ring.length > max.length ? ring : max,
null
)
const vertices = maxRing.map(coords => [
...projection(coords).reverse(),
zScale(height),
height
]);
for (let i = 0; i < Math.floor(height / breakSize); i++) {
vertices.push(
maxRing.map(coords => [
...projection(coords).reverse(),
zScale(i * breakSize),
i * breakSize
])
)
}
return { vertices, multi: true }
}
default:
return null;
}
};

// WARNING color is interpolated inaccurately (based on the extent of the tile heights,
// not absolute height) and should be fixed in a non-demo
const normElevation = d3.scaleLinear()
.domain(
[0, maxHeight]
// d3.extent(tile.features, f => getHeight(f)) /* as [number, number] */,
)
.range([0, 1]);
const lines = tile.features
// .filter(feature => +feature.properties.ELEVATION % 10 === 0)
.flatMap(feature => {
const verts = path(feature);
if (!verts) {
return null;
}

// unified building color
const buildingColor = colorScheme === 'random' &&
[util.rand(), util.rand(), util.rand(), 1];

return (verts.multi ? verts.vertices : [verts]).map(v => {
const segmentColor = colorScheme === 'randomSegment' &&
[util.rand(), util.rand(), util.rand(), 1];
return {
vertices: v,
color: v.map(vertex => {
switch (colorScheme) {
case 'random': return buildingColor;
case 'randomSegment': return segmentColor;
default: {
const color = d3.color(
d3[colorScheme](normElevation(vertex[3])),
)
return [...rgbFromStr(color?.formatRgb()), 1]
}
}
}),
}
});
})
.filter(Boolean);
return new LineRenderer(
device,
context,
lines,
);
}
async function main() {
let frame;
const { context, device } = await gpu.init(TILE_WIDTH, TILE_HEIGHT);
const renderer = await initLineRenderer({ context, device });

// camera
const projection = mat4.perspective((90 * Math.PI) / 180, TILE_WIDTH / TILE_HEIGHT, 0.01, 256);

let position = [0, 0, 0.6];
const target = [0, 0, 0];
const up = [0, 1, 0];
const rotation = -Math.PI / 3;
const view = mat4.lookAt(
[...position],
target,
up,
);

mat4.rotateX(view, rotation, view)

const viewProjection = mat4.multiply(projection, view);

const minZoom = 0.5;
const maxZoom = 5.0;
const zoomSpeed = 0.001;
function zoom(e) {
e.preventDefault();
const vd = [
target[0] - position[0],
target[1] - position[1],
target[2] - position[2],
];
const len = Math.sqrt(vd[0] * vd[0] + vd[1] * vd[1] + vd[2] * vd[2]);
vd[0] /= len;
vd[1] /= len;
vd[2] /= len;
const amt = e.deltaY * zoomSpeed;
const np = [
position[0] - vd[0] * amt,
position[1] - vd[1] * amt,
position[2] - vd[2] * amt
];
const dist = Math.sqrt(np[0] * np[0] + np[1] * np[1] + np[2] * np[2]);
if (dist >= minZoom && dist <= maxZoom) {
position = np;
mat4.lookAt(position, target, up, view);
mat4.rotateX(view, rotation, view);
mat4.multiply(projection, view, viewProjection);
}
}
function tick() {
let t = performance.now() * 0.0002;

const matrix = mat4.copy(viewProjection);
mat4.rotateZ(viewProjection, t, matrix);
renderer.render(matrix);
frame = requestAnimationFrame(tick);
}
frame = requestAnimationFrame(tick);

context.canvas.addEventListener('wheel', zoom);
// Specific to ObservableHQ.com:
invalidation.then(() => {
cancelAnimationFrame(frame);
renderer.cleanup();
device.destroy();
context.canvas.removeEventListener('wheel', zoom);
})
return { context }
}

return (await main()).context.canvas
}
Insert cell
LineRenderer = {
// prettier-ignore
const vertexShader = /* wgsl */ `
@group(0) @binding(0) var<uniform> matrix: mat4x4f;
struct VertexOut {
@builtin(position) position: vec4f,
@location(0) color: vec4f
}
@vertex
fn vs(
@location(0) position: vec4f,
@location(1) color: vec4f
) -> VertexOut {
var output: VertexOut;
// FIXME should pass width & height as uniforms instead of hardcoding
output.position = matrix * vec4f((position.xy / ${TILE_WIDTH / 2} - 1.0), position.z * 0.2, 1.0);
output.color = color * max(${baseOpacity}, position.z);
return output;
}
`;
const fragmentShader = /* wgsl */ `
@fragment
fn fs(@location(0) color: vec4f) -> @location(0) vec4f {
return color;
}
`;
class LineRenderer {
device /* : GPUDevice */;
context /* : CanvasRenderingContext2D */;
canvas /* : HTMLCanvasElement */;
vertexBuffer /* : GPUBuffer */;
colorBuffer /*: GPUBuffer */;
uniformBuffer /*: GPUBuffer */;
pipeline /*: GPURenderPipeline */;
options = {};
multisampleTexture;
renderPassDescriptor = {
colorAttachments: [
{
view: null,
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
}
constructor(device /*: GPUDevice */, context /*: CanvasRenderingContext2D */, lines) {
this.device = device;
this.canvas = context.canvas;
this.context = context;
const vertexData /*: number[] */ = [];
const colorData /*: number[] */ = [];
for (const line of lines) {
const { vertices, color } = line;
for (let i = 0; i < vertices.length - 1; i++) {
// FIXME performance for continuous lines
vertexData.push(
// Start
vertices[i][0],
vertices[i][1],
vertices[i][2],
1,
// End
vertices[i + 1][0],
vertices[i + 1][1],
vertices[i + 1][2],
1,
);

// prettier-ignore
colorData.push(
...color[i],
...color[i],
)
}
}
// Vertex buffer
this.vertexBuffer = gpu.mapBuffer(device, new Float32Array(vertexData), {
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
// Color buffer
this.colorBuffer = gpu.mapBuffer(device, new Float32Array(colorData), {
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
// Uniform buffer for transformation matrix
this.uniformBuffer = device.createBuffer({
size: 64, // 4x4 matrix = 16 floats = 64 bytes
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module: device.createShaderModule({ code: vertexShader }),
entryPoint: 'vs',
buffers: [
{
// Position buffer layout
arrayStride: 16, // 4 floats * 4 bytes
attributes: [
{
shaderLocation: 0,
offset: 0,
format: 'float32x4',
},
],
},
{
// Color buffer layout
arrayStride: 16, // 4 floats * 4 bytes
attributes: [
{
shaderLocation: 1,
offset: 0,
format: 'float32x4',
},
],
},
],
},
fragment: {
module: device.createShaderModule({ code: fragmentShader }),
entryPoint: 'fs',
targets: [
{
format: navigator.gpu.getPreferredCanvasFormat(),
blend: {
color: {
srcFactor: 'src-alpha',
dstFactor: 'one',
operation: 'add',
},
alpha: {
srcFactor: 'zero',
dstFactor: 'one',
operation: 'add',
}
},
},
],
},
primitive: {
topology: 'line-list',
},
multisample: {
count: 4,
},
});
this.bindGroup = device.createBindGroup({
layout: this.pipeline.getBindGroupLayout(0),
entries: [
{
binding: 0,
resource: { buffer: this.uniformBuffer },
},
],
});
this.vertexCount = vertexData.length / 4;
}
render(camera) {
this.device.queue.writeBuffer(this.uniformBuffer, 0, camera);

const commandEncoder = this.device.createCommandEncoder();
const canvasTexture = this.context.getCurrentTexture();

// Antialiasing
// https://webgpufundamentals.org/webgpu/lessons/webgpu-multisampling.html
if (!this.multisampleTexture ||
this.multisampleTexture.width !== canvasTexture.width ||
this.multisampleTexture.height !== canvasTexture.height) {
if (this.multisampleTexture) {
this.multisampleTexture.destroy();
}
this.multisampleTexture = this.device.createTexture({
format: canvasTexture.format,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
size: [canvasTexture.width, canvasTexture.height],
sampleCount: 4,
});
}
this.renderPassDescriptor.colorAttachments[0].view = this.multisampleTexture.createView();
this.renderPassDescriptor.colorAttachments[0].resolveTarget = canvasTexture.createView();

const renderPass = commandEncoder.beginRenderPass(this.renderPassDescriptor);
renderPass.setPipeline(this.pipeline);
renderPass.setBindGroup(0, this.bindGroup);
renderPass.setVertexBuffer(0, this.vertexBuffer);
renderPass.setVertexBuffer(1, this.colorBuffer);
renderPass.draw(this.vertexCount);
renderPass.end();
this.device.queue.submit([commandEncoder.finish()]);
}
cleanup() {
;[
this.vertexBuffer,
this.colorBuffer,
this.uniformBuffer,
this.multisampleTexture
].forEach(resource => resource.destroy());
}
}

return LineRenderer
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
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