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}