diff --git a/apps/typegpu-docs/src/examples/rendering/vector-field/index.html b/apps/typegpu-docs/src/examples/rendering/vector-field/index.html
new file mode 100644
index 0000000000..aa8cc321b3
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/vector-field/index.html
@@ -0,0 +1 @@
+
diff --git a/apps/typegpu-docs/src/examples/rendering/vector-field/index.ts b/apps/typegpu-docs/src/examples/rendering/vector-field/index.ts
new file mode 100644
index 0000000000..5975fa41cd
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/vector-field/index.ts
@@ -0,0 +1,150 @@
+import {
+ caps,
+ endCapSlot,
+ LineControlPoint,
+ lineSegmentIndices,
+ lineSegmentVariableWidth,
+ startCapSlot,
+} from '@typegpu/geometry';
+import tgpu, { d, std } from 'typegpu';
+import { defineControls } from '../../common/defineControls.ts';
+
+const root = await tgpu.init();
+
+const context = root.configureContext({
+ canvas: document.querySelector('canvas')!,
+ alphaMode: 'premultiplied',
+});
+
+const GRID_SIZE = 4;
+
+const MAX_JOIN_COUNT = 1;
+const indices = lineSegmentIndices(MAX_JOIN_COUNT);
+const indexBuffer = root.createBuffer(d.arrayOf(d.u16, indices.length), indices).$usage('index');
+
+const mainVertex = tgpu.vertexFn({
+ in: {
+ instanceIndex: d.builtin.instanceIndex,
+ vertexIndex: d.builtin.vertexIndex,
+ },
+ out: {
+ outPos: d.builtin.position,
+ },
+})(({ vertexIndex, instanceIndex: arrowIdx }) => {
+ 'use gpu';
+ const arrowX = arrowIdx % GRID_SIZE;
+ const arrowY = d.u32(arrowIdx / GRID_SIZE);
+ // An arrow pointing to the top-right corner
+ const startPos = d.vec2f(arrowX, arrowY) / GRID_SIZE;
+ const endPos = (d.vec2f(arrowX, arrowY) + 1) / GRID_SIZE;
+
+ const A = LineControlPoint({
+ position: startPos,
+ radius: 0.02,
+ });
+ const B = LineControlPoint({
+ position: std.mix(startPos, endPos, 0.25),
+ radius: 0.02,
+ });
+ const C = LineControlPoint({
+ position: std.mix(startPos, endPos, 0.75),
+ radius: 0.02,
+ });
+ const D = LineControlPoint({
+ position: endPos,
+ radius: 0.02,
+ });
+
+ const result = lineSegmentVariableWidth(vertexIndex, A, B, D, D, MAX_JOIN_COUNT);
+
+ return {
+ outPos: d.vec4f(result.vertexPosition, 0, 1),
+ };
+});
+
+const mainFragment = tgpu.fragmentFn({
+ out: d.vec4f,
+})(() => {
+ 'use gpu';
+ return d.vec4f(1, 0, 0, 1);
+});
+
+const pipeline = root
+ .with(startCapSlot, caps.butt)
+ .with(endCapSlot, caps.arrow)
+ .createRenderPipeline({
+ vertex: mainVertex,
+ fragment: mainFragment,
+ })
+ .withIndexBuffer(indexBuffer)
+ .withColorAttachment({
+ view: context,
+ clearValue: [1, 1, 1, 1],
+ });
+
+const draw = () => {
+ pipeline.drawIndexed(indices.length, GRID_SIZE * GRID_SIZE);
+};
+
+function frame() {
+ draw();
+ frameId = requestAnimationFrame(frame);
+}
+
+let frameId = requestAnimationFrame(frame);
+
+// #region Example controls & Cleanup
+
+export const controls = defineControls({
+ Randomize: {
+ onButtonClick() {},
+ },
+});
+
+export function onCleanup() {
+ root.destroy();
+ cancelAnimationFrame(frameId);
+}
+
+// #endregion
+
+/*
+import tgpu, { d, std } from 'typegpu';
+import { randf } from '@typegpu/noise';
+import type { AnyWgslData } from 'typegpu/data';
+
+// { device: { optionalFeatures: ['shader-f16'] } }
+const root = await tgpu.init();
+
+const size = 4;
+
+const arrayNxN = (element: T, w: number, h: number) =>
+ d.arrayOf(d.arrayOf(element, w), h);
+
+const displacementBuffer = root.createBuffer(arrayNxN(d.vec2h, size, size)).$usage('storage');
+const displacementPackedBuffer = root
+ .createBuffer(arrayNxN(d.u32, size, size), root.unwrap(displacementBuffer))
+ .$usage('storage');
+
+const displacementView = displacementBuffer.as('mutable');
+const displacementPackedView = displacementPackedBuffer.as('mutable');
+
+function main(x: number, y: number) {
+ 'use gpu';
+
+ const dir = randf.onUnitCircle();
+
+ if (std.extensionEnabled('f16')) {
+ displacementView.$[x][y] = d.vec2h(dir);
+ } else {
+ displacementPackedView.$[x][y] = std.pack2x16float(dir);
+ }
+}
+
+const pipeline = root.createGuardedComputePipeline(main);
+
+pipeline.dispatchThreads(size, size);
+
+console.log(await displacementBuffer.read());
+
+*/
diff --git a/apps/typegpu-docs/src/examples/rendering/vector-field/meta.json b/apps/typegpu-docs/src/examples/rendering/vector-field/meta.json
new file mode 100644
index 0000000000..502367de7d
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/vector-field/meta.json
@@ -0,0 +1,7 @@
+{
+ "title": "Vector Field",
+ "category": "simulation",
+ "tags": ["ecosystem", "line-rendering", "particles", "vector field"],
+ "dev": true,
+ "coolFactor": 7
+}
diff --git a/apps/typegpu-docs/src/examples/rendering/vector-field/simple-line.ts b/apps/typegpu-docs/src/examples/rendering/vector-field/simple-line.ts
new file mode 100644
index 0000000000..4884d855a6
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/vector-field/simple-line.ts
@@ -0,0 +1,54 @@
+import { LineControlPoint, lineSegmentIndices, lineSegmentVariableWidth } from '@typegpu/geometry';
+import tgpu, { d, type TgpuRoot } from 'typegpu';
+
+export function createLine(root: TgpuRoot, () => { }) {
+ const MAX_JOIN_COUNT = 1;
+ const indices = lineSegmentIndices(MAX_JOIN_COUNT);
+
+ const maxIndex = Math.max(...indices);
+ const indexBuffer = root.createBuffer(d.arrayOf(d.u16, indices.length), indices).$usage('index');
+
+ const vertex = tgpu.vertexFn({
+ in: {
+ instanceIndex: d.builtin.instanceIndex,
+ vertexIndex: d.builtin.vertexIndex,
+ },
+ out: {
+ outPos: d.builtin.position,
+ },
+ })(({ vertexIndex, instanceIndex: arrowIdx }) => {
+ 'use gpu';
+ const arrowX = arrowIdx % GRID_SIZE;
+ const arrowY = d.u32(arrowIdx / GRID_SIZE);
+ // An arrow pointing to the top-right corner
+ const startPos = d.vec2f(arrowX, arrowY) / GRID_SIZE;
+ const endPos = (d.vec2f(arrowX, arrowY) + 1) / GRID_SIZE;
+
+ const A = LineControlPoint({
+ position: startPos,
+ radius: 0.02,
+ });
+ const B = LineControlPoint({
+ position: std.mix(startPos, endPos, 0.25),
+ radius: 0.02,
+ });
+ const C = LineControlPoint({
+ position: std.mix(startPos, endPos, 0.75),
+ radius: 0.02,
+ });
+ const D = LineControlPoint({
+ position: endPos,
+ radius: 0.02,
+ });
+
+ const result = lineSegmentVariableWidth(vertexIndex, A, B, D, D, MAX_JOIN_COUNT);
+
+ return {
+ outPos: d.vec4f(result.vertexPosition, 0, 1),
+ };
+ });
+
+ return {
+ vertex,
+ };
+}
diff --git a/apps/typegpu-docs/src/examples/rendering/vector-field/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/vector-field/thumbnail.png
new file mode 100644
index 0000000000..0bc847b508
Binary files /dev/null and b/apps/typegpu-docs/src/examples/rendering/vector-field/thumbnail.png differ