diff --git a/src/components/EditableSketch/index.astro b/src/components/EditableSketch/index.astro
index 45520d6d0d..51a939cdfa 100644
--- a/src/components/EditableSketch/index.astro
+++ b/src/components/EditableSketch/index.astro
@@ -10,6 +10,7 @@ interface Props {
height?: number;
base?: string;
scripts?: string[];
+ includeWebGPU?: boolean;
}
const props = Astro.props as Props;
@@ -38,6 +39,7 @@ const scrollable = props.scrollable ?? false;
allowSideBySide={!vertical}
fullWidth={!vertical}
scripts={props.scripts}
+ includeWebGPU={props.includeWebGPU}
cssCode={props.cssCode}
htmlBodyCode={props.bodyCode}
scrollable={scrollable}
diff --git a/src/content/examples/config.ts b/src/content/examples/config.ts
index be0ac2405b..96837f3a40 100644
--- a/src/content/examples/config.ts
+++ b/src/content/examples/config.ts
@@ -21,6 +21,9 @@ export const examplesCollection = defineCollection({
// Aria label used for the live example code
arialabel: z.string().optional(),
featured: z.boolean().optional(),
+ // Additional libraries required by the embedded sketch.
+ scripts: z.array(z.url()).optional(),
+ webgpu: z.boolean().optional(),
relatedReference: z.array(reference("reference")).optional(),
featuredImage: image(),
featuredImageAlt: z.string().optional().default(""),
diff --git a/src/content/examples/en/16_Shaders/10_Body_Field/code.js b/src/content/examples/en/16_Shaders/10_Body_Field/code.js
new file mode 100644
index 0000000000..738a9beab1
--- /dev/null
+++ b/src/content/examples/en/16_Shaders/10_Body_Field/code.js
@@ -0,0 +1,120 @@
+/*
+ * Inspired by
+ * Golan's https://openprocessing.org/@golan/2405195
+ * and Dave's https://editor.p5js.org/davepagurek/sketches/dTizHPqbM
+ */
+
+let video;
+let bodyPose;
+let poses = [];
+let connections;
+let dataStorage;
+let bodyFieldShader;
+
+const loopN = 100;
+const pointN = 100;
+
+// Adapted from https://openprocessing.org/@golan/2405195
+function bodyField() {
+ const points = uniformStorage(dataStorage);
+ const progress = uniformFloat("u_progress");
+ const green = uniformFloat("u_green");
+ filterColor.begin();
+ const coord = filterColor.texCoord;
+
+ // Find the distance from this pixel to the nearest body point.
+ let minDist = 1.0;
+ for (let i = 0; i < 100; i++) {
+ const point = [points[i].x, points[i].y];
+ minDist = min(minDist, length(coord - point));
+ }
+ const r = 6.0 * minDist;
+ const g = green * coord.y;
+ const b = 0.5 + 0.5 * cos(TWO_PI * progress);
+ filterColor.set([r, g, b, 1]);
+ filterColor.end();
+}
+
+async function setup() {
+ await createCanvas(640, 640, WEBGPU);
+
+ // Share a fixed-size array of body points with the GPU.
+ dataStorage = createStorage(emptyPoints());
+ bodyFieldShader = buildFilterShader(bodyField);
+ bodyPose = await ml5.bodyPose();
+ video = createCapture(VIDEO);
+ video.size(width, height);
+ video.hide();
+ connections = bodyPose.getSkeleton();
+ bodyPose.detectStart(video, gotPoses);
+}
+
+function draw() {
+ if (!connections || !dataStorage) return;
+ const points = emptyPoints();
+ let count = 0;
+ for (const pose of poses) {
+ for (const [a, b] of connections) {
+ if (count >= pointN) break;
+ const pointA = pose.keypoints[a];
+ const pointB = pose.keypoints[b];
+ if (pointA.confidence <= 0.1 || pointB.confidence <= 0.1) continue;
+
+ // Use each skeleton segment's midpoint as a point in the field.
+ const x = (pointA.x + pointB.x) / 2;
+ const y = (pointA.y + pointB.y) / 2;
+
+ // Normalize to shader coordinates and mirror the webcam horizontally.
+ points[count++] = { x: 1 - x / width, y: y / height };
+ }
+ }
+ dataStorage.update(points);
+ const t = millis() / 1000;
+ const progress = (frameCount % loopN) / loopN;
+ bodyFieldShader.setUniform("u_progress", progress);
+ bodyFieldShader.setUniform("u_green", noise(t + 1));
+ filter(bodyFieldShader);
+}
+
+function emptyPoints() {
+ // Keep unused points far outside the canvas.
+ return Array.from({ length: pointN }, () => ({ x: -10, y: -10 }));
+}
+
+function gotPoses(results) {
+ // Track one person and ease toward each new pose to reduce jitter.
+ const smoothing = 0.2;
+ poses = results.slice(0, 1).map((pose, i) => {
+ const previousPose = poses[i];
+ if (!previousPose) return pose;
+ return {
+ ...pose,
+ keypoints: pose.keypoints.map((point, j) => {
+ const previousPoint = previousPose.keypoints[j];
+ if (!previousPoint) return point;
+ return {
+ ...point,
+ x: lerp(previousPoint.x, point.x, smoothing),
+ y: lerp(previousPoint.y, point.y, smoothing),
+ confidence: lerp(previousPoint.confidence, point.confidence, smoothing),
+ };
+ }),
+ };
+ });
+
+ // Merge facial landmarks so the head contributes a single location.
+ for (const pose of poses) {
+ const leftEye = pose.keypoints.find((p) => p.name === "left_eye");
+ const rightEye = pose.keypoints.find((p) => p.name === "right_eye");
+ if (!(leftEye?.confidence > 0.1 && rightEye?.confidence > 0.1)) continue;
+ const x = (leftEye.x + rightEye.x) / 2;
+ const y = (leftEye.y + rightEye.y) / 2;
+ const merged = ["nose", "left_eye", "right_eye", "left_ear", "right_ear"];
+ for (const point of pose.keypoints) {
+ if (merged.includes(point.name)) {
+ point.x = x;
+ point.y = y;
+ }
+ }
+ }
+}
diff --git a/src/content/examples/en/16_Shaders/10_Body_Field/description.mdx b/src/content/examples/en/16_Shaders/10_Body_Field/description.mdx
new file mode 100644
index 0000000000..44d4bb1aa5
--- /dev/null
+++ b/src/content/examples/en/16_Shaders/10_Body_Field/description.mdx
@@ -0,0 +1,34 @@
+---
+featuredImage: "../../../images/featured/16_Shaders_10_body_field.png"
+featuredImageAlt: Soft blue and purple circles trace a person's body against a pink background.
+title: Body Field (p5.strands)
+oneLineDescription: Turn webcam body tracking into a colorful distance field with p5.strands and ml5.js.
+featured: false
+webgpu: true
+scripts:
+ - https://unpkg.com/ml5@1/dist/ml5.js
+
+remix:
+ - description: Created by
+ attribution:
+ - name: Bairui Su
+ URL: https://bairui.dev/
+ code:
+ - label: Body Field source
+ URL: https://github.com/processing/p5.js-website/tree/main/src/content/examples/en/16_Shaders/10_Body_Field
+ - description: Inspired by
+ attribution:
+ - name: Golan Levin
+ URL: https://openprocessing.org/@golan
+ - name: Dave Pagurek
+ URL: https://www.davepagurek.com/
+ - collectivelyAttributedSince: 2026
+---
+
+Move in front of your webcam to shape a colorful field. The ml5.js BodyPose model
+tracks your body, and the midpoints of its skeleton connections are passed to a
+p5.strands filter shader using a storage buffer. The shader colors each pixel
+based on its distance to the nearest point. Pose smoothing keeps the movement fluid.
+
+This example requires a browser with WebGPU support and access to your webcam.
+Allow camera access when prompted, and wait for the body tracking model to load.
diff --git a/src/content/examples/images/featured/16_Shaders_10_body_field.png b/src/content/examples/images/featured/16_Shaders_10_body_field.png
new file mode 100644
index 0000000000..ceda4c925f
Binary files /dev/null and b/src/content/examples/images/featured/16_Shaders_10_body_field.png differ
diff --git a/src/layouts/ExampleLayout.astro b/src/layouts/ExampleLayout.astro
index 96c11f7d1e..18c01f96f8 100644
--- a/src/layouts/ExampleLayout.astro
+++ b/src/layouts/ExampleLayout.astro
@@ -166,7 +166,11 @@ const remixHistoryHasCodeLinks = remixData?.some(
-
{example.data.arialabel}