diff --git a/.github/workflows/processing.yml b/.github/workflows/processing.yml index 7c564b5..f6174c4 100644 --- a/.github/workflows/processing.yml +++ b/.github/workflows/processing.yml @@ -55,25 +55,20 @@ jobs: mkdir -p "${RUNNER_TEMP}/processing-4.5.6" unzip -q "${RUNNER_TEMP}/processing.zip" -d "${RUNNER_TEMP}/processing-4.5.6" - - name: Compile the PDE sketch with Processing 4.5.6 + - name: Compile and render all sketches; reject invalid and missing shaders run: | PROCESSING_BIN=$(find "${RUNNER_TEMP}/processing-4.5.6" -type f \( -name processing -o -name Processing \) -print -quit) test -n "${PROCESSING_BIN}" chmod +x "${PROCESSING_BIN}" - "${PROCESSING_BIN}" cli \ - --sketch="${GITHUB_WORKSPACE}/p5f_sample" \ - --output="${RUNNER_TEMP}/p5f-sample-build" \ - --force \ - --build + xvfb-run --auto-servernum ./scripts/verify-sketches.sh \ + "${PROCESSING_BIN}" "${RUNNER_TEMP}/sketch-verification" - - name: Run a ten-frame P3D smoke test - run: | - set -o pipefail - PROCESSING_BIN=$(find "${RUNNER_TEMP}/processing-4.5.6" -type f \( -name processing -o -name Processing \) -print -quit) - timeout 30s xvfb-run --auto-servernum "${PROCESSING_BIN}" cli \ - --sketch="${GITHUB_WORKSPACE}/p5f_sample" \ - --output="${RUNNER_TEMP}/p5f-sample-run" \ - --force \ - --run \ - --smoke-test | tee "${RUNNER_TEMP}/p5f-sample-smoke.log" - grep -q "SMOKE_TEST_OK" "${RUNNER_TEMP}/p5f-sample-smoke.log" + - name: Preserve rendered frames and runtime logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: processing-render-verification + path: | + ${{ runner.temp }}/sketch-verification/**/frame.png + ${{ runner.temp }}/sketch-verification/**/runtime.log + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index b066599..5e89def 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target/ .DS_Store *.class +**/captures/ diff --git a/LICENSE-new-examples b/LICENSE-new-examples new file mode 100644 index 0000000..0c55f2a --- /dev/null +++ b/LICENSE-new-examples @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2026 Daito Manabe + +This license applies only to the newly written .pde and GLSL shader files +under motion_ribbons/ and motion_field/, the SketchRun.pde helper in +p5f_sample/, scripts/verify-sketches.sh, and tests/shaders/. It does not apply to the +pre-existing p5f_sample source, BVHParser JAR/source/API documentation, +or original BVH motion data. Copies of that existing parser in each +new sketch's code/ directory retain their existing copyright status. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 074cfc5..f30fb28 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,43 @@ -# Perfume Global Site Project — Processing example +# Perfume Global Site Project — Processing examples [![Processing 4](https://github.com/perfume-dev/example-processing/actions/workflows/processing.yml/badge.svg)](https://github.com/perfume-dev/example-processing/actions/workflows/processing.yml) -A Processing sketch that visualizes the three bundled BVH motion-capture files as animated point skeletons. This repository originated with the 2012 Perfume Global Site Project and is preserved as a small, runnable creative-coding example. +Three small, runnable motion-visualization sketches for Processing 4.5.6. The original 2012 Perfume Global Site Project example now uses retained `PShape` geometry, `PVector` coordinates, typed collections, and an explicit playback clock. Two new shader studies turn the same BVH recordings into ribbons and contour fields. + +| Sketch | What to learn | Renderer | +| --- | --- | --- | +| [`p5f_sample`](p5f_sample/p5f_sample.pde) | BVH playback; update a retained skeleton with `PShape.setVertex()` | P3D | +| [`motion_ribbons`](motion_ribbons/) | Cached joint trajectories; triangle strips; vertex + fragment shaders | P3D | +| [`motion_field`](motion_field/) | Send joint positions as uniforms; draw isocontours and interference in one fragment pass | P2D | + +![Motion ribbons, rendered by Processing](docs/images/motion-ribbons.png) + +![Motion field, rendered by Processing](docs/images/motion-field.png) + +These are actual rendered frames from the bundled recordings, at 16 seconds and 8 seconds respectively. ## Compatibility - [Processing 4.5.6](https://processing.org/download/) (Java mode) -- macOS, Windows, or Linux with OpenGL support for the `P3D` renderer +- macOS, Windows, or Linux with OpenGL support for the `P2D` and `P3D` renderers - No separately installed Processing libraries -The bundled `p5f_sample/code/BVHParser.jar` is built from the source in `p5f_sample/lib/src` against Processing Core 4.5.6 and Java 17. +The bundled `code/BVHParser.jar` in each sketch is the same small library built from `p5f_sample/lib/src` against Processing Core 4.5.6 and Java 17. CI checks all copies against the reproducible Maven build. [Processing's `PShader`](https://processing.org/reference/PShader) exposes vertex and fragment stages; the ribbon topology is generated on the CPU. These sketches do not require a geometry-shader extension or a separately installed library. ## Run the sketch 1. Install Processing 4.5.6. -2. Open `p5f_sample/p5f_sample.pde` in Processing. -3. Press **Run**. +2. Download or clone this entire repository. +3. Open `p5f_sample/p5f_sample.pde`, `motion_ribbons/motion_ribbons.pde`, or `motion_field/motion_field.pde` in Processing. +4. Press **Run**. -The sketch loads `A_test.bvh`, `B_test.bvh`, and `C_test.bvh` from its `data` directory and plays all three motions in a continuously orbiting 3D view. +The original BVH files remain in `p5f_sample/data/`. The new sketches resolve this directory relative to their own sketch directory, so the repository can live anywhere. For **Export Application** or a standalone sketch folder, copy `A_test.bvh`, `B_test.bvh`, and `C_test.bvh` into that sketch's own `data/` directory first; local files take priority. The original data has not been modified or duplicated in Git. + +All views recenter each dancer's root X/Z position and place the dancers side by side, retaining the recorded joint poses and vertical movement. This is a visualization layout, not the original stage formation. `MotionData.position()` (or `PBvh.stagePosition()`) is the small coordinate-conversion function to change when preserving the original translation is desired. + +`Space` pauses; `R` restarts; `H` toggles labels; `S` saves a PNG under the sketch's `captures/` folder. Drag to orbit the 3D views. In the field sketch, `+` / `-` change contour density and `J` displays the driving joints. The views adapt to window size, and playback continues while another application has focus. + +日本語: Processingで各フォルダーの同名 `.pde` を開けば実行できます。リボンは「関節の軌跡 → 三角形ストリップ → 頂点・フラグメントシェーダー」、フィールドは「関節座標 → uniform → 等高線」という最小構成です。元のモーションデータを共有するため、最初はリポジトリ全体をダウンロードしてください。 ## Development and verification @@ -33,6 +52,8 @@ After changing the Java library source, rebuild the bundled Processing JAR and i ```sh mvn -B package javadoc:javadoc cp target/BVHParser.jar p5f_sample/code/BVHParser.jar +cp target/BVHParser.jar motion_ribbons/code/BVHParser.jar +cp target/BVHParser.jar motion_field/code/BVHParser.jar rm -rf p5f_sample/lib/doc cp -R target/reports/apidocs p5f_sample/lib/doc find p5f_sample/lib/doc -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \ @@ -40,7 +61,23 @@ find p5f_sample/lib/doc -type f \( -name '*.html' -o -name '*.css' -o -name '*.j -exec perl -pi -e 's/[ \t]+$//' {} + ``` -CI additionally compiles the complete PDE sketch with the official Processing 4.5.6 command-line tool and verifies that the committed JAR is identical to the reproducible Maven build. +Use the official Processing executable (`Processing` on macOS, `processing` on Linux) to compile and render all sketches: + +```sh +./scripts/verify-sketches.sh /path/to/Processing /path/to/test-output +``` + +On Linux without a desktop, prefix the command with `xvfb-run --auto-servernum`. Each run has a 60-second timeout. CI uses this same script, renders all three sketches at 8 and 16 seconds, checks that the two images differ, verifies nonempty framebuffers and shader link status, and confirms that missing or malformed shaders fail. PNGs and runtime logs are retained as workflow artifacts. The two helper tabs (`SketchRun.pde` and `MotionData.pde`) are kept byte-identical across their copies so that each sketch opens directly in the PDE without preprocessing or extra setup. + +For one deterministic capture (the parent output directory must exist): + +```sh +/path/to/Processing cli --sketch="$(pwd)/motion_ribbons" \ + --output=/path/to/a-new-build-directory --run \ + --smoke-test --time=16 --capture=/path/to/ribbons.png +``` + +`--smoke-test` / `--capture` use a fixed timeline, hide labels, suppress window focus requests, and exit after the captured frame. Normal interactive playback uses elapsed time. For automated captures on macOS, `JAVA_TOOL_OPTIONS=-Dapple.awt.UIElement=true` also prevents a transient Dock application from activating. ## Project stewardship and credits @@ -50,4 +87,4 @@ The repository history credits [Satoru Higa](https://github.com/satoruhiga) for ## License -No license file was included in the original repository. Unless and until the maintainers add one, the source and bundled motion data remain subject to their respective owners' copyright; public availability does not by itself grant reuse rights. +The newly written shader examples and test helper are MIT-licensed as scoped explicitly in [`LICENSE-new-examples`](LICENSE-new-examples), copyright 2026 Daito Manabe. This does **not** relicense the pre-existing source, original parser (including its copied JARs), or motion data. No license file was included with those original materials; they remain subject to their respective owners' copyright, and public availability does not by itself grant reuse rights. diff --git a/docs/images/motion-field.png b/docs/images/motion-field.png new file mode 100644 index 0000000..0c69de4 Binary files /dev/null and b/docs/images/motion-field.png differ diff --git a/docs/images/motion-ribbons.png b/docs/images/motion-ribbons.png new file mode 100644 index 0000000..5e953b3 Binary files /dev/null and b/docs/images/motion-ribbons.png differ diff --git a/motion_field/MotionData.pde b/motion_field/MotionData.pde new file mode 100644 index 0000000..044093a --- /dev/null +++ b/motion_field/MotionData.pde @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT; see ../LICENSE-new-examples +class MotionData { + final BvhParser parser = new BvhParser(); + + MotionData(String filename) { + // Prefer local data when exporting; share the original files in a checkout. + java.io.File local = new java.io.File(dataPath(filename)); + java.io.File shared = new java.io.File(sketchPath("../p5f_sample/data/" + filename)); + java.io.File source = local.isFile() ? local : shared; + if (!source.isFile()) throw new IllegalStateException( + "Missing " + filename + ". Keep the full repository together, or copy the BVH files into this sketch's data folder."); + parser.parse(loadStrings(source)); + } + + void update(float seconds) { + parser.moveMsTo(round(seconds * 1000)); + parser.update(); + } + + BvhBone joint(String name) { + for (BvhBone bone : parser.getBones()) if (bone.getName().equals(name)) return bone; + throw new IllegalArgumentException("Required BVH joint not found: " + name); + } + + PVector position(PVector point) { + PVector root = parser.getBones().get(0).absPos; + return new PVector(-(point.x - root.x) * 1.55, (85 - point.y) * 1.55, + -(point.z - root.z) * 1.55); + } +} diff --git a/motion_field/README.md b/motion_field/README.md new file mode 100644 index 0000000..6682ddc --- /dev/null +++ b/motion_field/README.md @@ -0,0 +1,16 @@ +# Motion field + +![Actual Processing render at 8 seconds](../docs/images/motion-field.png) + +Open `motion_field.pde` in Processing 4.5.6 and press Run. Keep the full repository together so the sketch can load the shared files from `../p5f_sample/data/`. No additional Processing library is required. For export, copy those three BVH files into this folder's `data/` directory first. + +Six joints from each of the three recordings become an array of eighteen `vec3` uniforms: X, Y, and dancer number. One fullscreen rectangle runs `data/field.frag`; its pixels sum joint influence, draw logarithmic isocontours, and mix a small interference term. Screen derivatives keep contour edges smooth at different resolutions. The third uniform component chooses the restrained cyan/coral/ivory palette. + +- `motion_field.pde`: BVH-to-uniform conversion, shader pass, controls. +- `data/field.frag`: field, contour spacing, antialiasing, and palette. +- `MotionData.pde`: legacy BVH adapter; recenters root X/Z for the side-by-side layout. +- `SketchRun.pde`: shader validation, deterministic capture, and smoke test. + +Space pauses; + / - change contour density; J reveals the driving joints; R resets; H toggles labels; S saves a screenshot. Window resizing preserves the shape proportions. Change the six entries in `jointNames` to try different body landmarks, or change the `influence` and `wave` expressions to explore another field. + +See the [root README](../README.md#development-and-verification) for deterministic CLI captures. Only the new sketch/shader code is covered by [`LICENSE-new-examples`](../LICENSE-new-examples); the original BVH data and parser JAR retain their existing copyright status. diff --git a/motion_field/SketchRun.pde b/motion_field/SketchRun.pde new file mode 100644 index 0000000..fedc08e --- /dev/null +++ b/motion_field/SketchRun.pde @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT; see ../LICENSE-new-examples +import java.nio.IntBuffer; +import processing.opengl.PGL; +import processing.opengl.PShader; + +// Automated runs retain an OpenGL window, but suppress its focus request. +protected PSurface initSurface() { + PSurface result = super.initSurface(); + if (args != null) for (String arg : args) { + if (arg.equals("--smoke-test") || arg.startsWith("--capture=")) { + ((com.jogamp.newt.opengl.GLWindow) result.getNative()).setFocusAction(() -> true); + } + } + return result; +} + +class SketchRun { + boolean automated = false; + boolean paused = false; + String capturePath; + float captureTime = 8; + float elapsed = 0; + int previousMillis = millis(); + final int captureFrame = 12; + + SketchRun() { + if (args != null) for (String arg : args) { + if (arg.equals("--smoke-test")) automated = true; + if (arg.startsWith("--capture=")) { + capturePath = arg.substring("--capture=".length()); + if (!new java.io.File(capturePath).isAbsolute()) { + throw new IllegalArgumentException("--capture requires an absolute output path"); + } + automated = true; + } + if (arg.startsWith("--time=")) captureTime = Float.parseFloat(arg.substring(7)); + } + if (!Float.isFinite(captureTime) || captureTime < 0 || captureTime > 3600) { + throw new IllegalArgumentException("--time must be finite, between 0 and 3600 seconds"); + } + } + + float seconds() { + int now = millis(); + if (!paused) elapsed += min((now - previousMillis) / 1000.0, 0.1); + previousMillis = now; + return automated ? max(0, captureTime + (frameCount - captureFrame) / 60.0) : elapsed; + } + + void togglePause() { paused = !paused; } + void reset() { elapsed = 0; paused = false; previousMillis = millis(); } + + PShader checkedShader(String fragment, String vertex) { + try { + if (!new java.io.File(dataPath(fragment)).isFile() || + (vertex != null && !new java.io.File(dataPath(vertex)).isFile())) { + throw new IllegalStateException("A required shader file is missing"); + } + PShader program = vertex == null ? loadShader(fragment) : loadShader(fragment, vertex); + // Processing can print a shader error and continue. Make that a test failure. + program.init(); + PGL gl = beginPGL(); + IntBuffer linked = IntBuffer.allocate(1); + gl.getProgramiv(program.glProgram, PGL.LINK_STATUS, linked); + String log = gl.getProgramInfoLog(program.glProgram); + endPGL(); + if (linked.get(0) != 1) throw new IllegalStateException("Shader did not link: " + log); + return program; + } catch (RuntimeException failure) { + System.err.println("SHADER_ERROR: " + failure.getMessage()); + // JOGL shutdown hooks can deadlock when compilation fails on its draw + // thread. Test failures exit immediately; normal runs use exit(). + if (automated) Runtime.getRuntime().halt(1); + throw failure; + } + } + + void finishFrame(String name, int motions) { + if (!automated || frameCount < captureFrame) return; + loadPixels(); + int litPixels = 0; + for (int pixel : pixels) { + if (max((pixel >> 16) & 255, max((pixel >> 8) & 255, pixel & 255)) > 65) litPixels++; + } + if (litPixels < 600) { + System.err.println("SMOKE_TEST_FAILED: rendered frame is unexpectedly empty"); + Runtime.getRuntime().halt(1); + } + if (capturePath != null) { + save(capturePath); + java.io.File output = new java.io.File(capturePath); + if (!output.isFile() || output.length() < 1000) throw new IllegalStateException("Capture was not saved"); + } + println("SMOKE_TEST_OK sketch=" + name + " frames=" + frameCount + + " motions=" + motions + " litPixels=" + litPixels + " focused=" + focused); + exit(); + } +} diff --git a/motion_field/code/BVHParser.jar b/motion_field/code/BVHParser.jar new file mode 100644 index 0000000..45d8488 Binary files /dev/null and b/motion_field/code/BVHParser.jar differ diff --git a/motion_field/data/field.frag b/motion_field/data/field.frag new file mode 100644 index 0000000..71db025 --- /dev/null +++ b/motion_field/data/field.frag @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT +#ifdef GL_ES +#extension GL_OES_standard_derivatives : enable +precision highp float; +precision mediump int; +#endif +#define PROCESSING_COLOR_SHADER +uniform vec2 resolution; +uniform float time; +uniform float density; +uniform vec3 sources[18]; + +vec3 palette(float id) { + if (id < 0.5) return vec3(0.24, 0.86, 0.84); + if (id < 1.5) return vec3(1.0, 0.38, 0.30); + return vec3(0.94, 0.86, 0.66); +} + +void main() { + float fieldScale = min(resolution.y, resolution.x / 1.6); + vec2 p = (gl_FragCoord.xy - resolution * 0.5) / fieldScale; + vec3 ink = vec3(0.0); + float total = 0.0; + float wave = 0.0; + float nearest = 10.0; + for (int i = 0; i < 18; i++) { + float d = length(p - sources[i].xy); + float influence = 0.007 / (d * d + 0.006); + total += influence; + ink += palette(sources[i].z) * influence; + wave += sin(d * 49.0 - time * 1.6 + float(i) * 0.36) * exp(-d * 7.0); + nearest = min(nearest, d); + } + ink /= max(total, 0.001); + float level = log(1.0 + total) * 14.0 * density + wave * 0.24; + float band = abs(fract(level) - 0.5); + float pixelWidth = max(fwidth(level), 0.004); + float line = 1.0 - smoothstep(pixelWidth * 0.45, pixelWidth * 1.4, band); + float envelope = smoothstep(0.14, 0.9, total); + float halo = exp(-nearest * 17.0); + vec3 base = vec3(0.022, 0.037, 0.060); + vec3 color = base + ink * (line * 0.58 * envelope + halo * 0.07); + color += vec3(0.50, 0.64, 0.69) * pow(halo, 9.0) * 0.25; + float vignette = 1.0 - smoothstep(0.42, 1.0, length(p * vec2(0.65, 1.0))); + gl_FragColor = vec4(color * (0.4 + 0.6 * vignette), 1.0); +} diff --git a/motion_field/motion_field.pde b/motion_field/motion_field.pde new file mode 100644 index 0000000..4a29be9 --- /dev/null +++ b/motion_field/motion_field.pde @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT; see ../LICENSE-new-examples +import com.rhizomatiks.bvh.BvhBone; +import com.rhizomatiks.bvh.BvhParser; +import java.util.List; +import java.util.ArrayList; + +final List dancers = new ArrayList<>(); +final String[] jointNames = { "Head", "RightWrist", "LeftWrist", "RightAnkle", "LeftAnkle", "Hips" }; +final float[] sources = new float[18 * 3]; +PShader fieldShader; +SketchRun run; +boolean showHelp = true; +boolean showJoints = false; +float contourDensity = 1; + +void settings() { + size(1280, 800, P2D); + pixelDensity(1); + noSmooth(); +} + +void setup() { + surface.setTitle("Perfume / motion field"); + surface.setResizable(true); + frameRate(60); + run = new SketchRun(); + showHelp = !run.automated; + fieldShader = run.checkedShader("field.frag", null); + for (String filename : new String[] { "A_test.bvh", "B_test.bvh", "C_test.bvh" }) { + dancers.add(new MotionData(filename)); + } + textFont(createFont("SansSerif", 13)); +} + +void draw() { + float time = run.seconds(); + for (int dancer = 0; dancer < dancers.size(); dancer++) { + MotionData motion = dancers.get(dancer); + motion.update(time); + for (int joint = 0; joint < jointNames.length; joint++) { + PVector p = motion.position(motion.joint(jointNames[joint]).absPos); + int index = (dancer * jointNames.length + joint) * 3; + sources[index] = (dancer - 1) * 0.48 + p.x / 640.0; + sources[index + 1] = -p.y / 540.0; + sources[index + 2] = dancer; + } + } + fieldShader.set("resolution", float(width), float(height)); + fieldShader.set("time", time); + fieldShader.set("density", contourDensity); + fieldShader.set("sources", sources, 3); + shader(fieldShader); + noStroke(); + fill(255); + rect(0, 0, width, height); + resetShader(); + if (showJoints) { + fill(245, 246, 235); + float fieldScale = min(height, width / 1.6); + for (int i = 0; i < sources.length; i += 3) { + circle(width / 2.0 + sources[i] * fieldScale, height / 2.0 - sources[i + 1] * fieldScale, 4); + } + } + if (showHelp) { + fill(228, 237, 239); + text("PERFUME / MOTION FIELD", 38, 43); + fill(126, 145, 160); + text("02 / eighteen BVH joints > uniforms > fragment shader", 38, 66); + text("SPACE pause +/- contours J joints R reset H labels S screenshot", 38, height - 32); + } + run.finishFrame("motion_field", dancers.size()); +} + +void keyPressed() { + if (key == ' ') run.togglePause(); + if (key == 'r' || key == 'R') { run.reset(); contourDensity = 1; } + if (key == 'j' || key == 'J') showJoints = !showJoints; + if (key == 'h' || key == 'H') showHelp = !showHelp; + if (key == '+' || key == '=') contourDensity = min(2.5, contourDensity + 0.1); + if (key == '-') contourDensity = max(0.3, contourDensity - 0.1); + if (key == 's' || key == 'S') saveFrame("captures/field-####.png"); +} diff --git a/motion_ribbons/MotionData.pde b/motion_ribbons/MotionData.pde new file mode 100644 index 0000000..044093a --- /dev/null +++ b/motion_ribbons/MotionData.pde @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT; see ../LICENSE-new-examples +class MotionData { + final BvhParser parser = new BvhParser(); + + MotionData(String filename) { + // Prefer local data when exporting; share the original files in a checkout. + java.io.File local = new java.io.File(dataPath(filename)); + java.io.File shared = new java.io.File(sketchPath("../p5f_sample/data/" + filename)); + java.io.File source = local.isFile() ? local : shared; + if (!source.isFile()) throw new IllegalStateException( + "Missing " + filename + ". Keep the full repository together, or copy the BVH files into this sketch's data folder."); + parser.parse(loadStrings(source)); + } + + void update(float seconds) { + parser.moveMsTo(round(seconds * 1000)); + parser.update(); + } + + BvhBone joint(String name) { + for (BvhBone bone : parser.getBones()) if (bone.getName().equals(name)) return bone; + throw new IllegalArgumentException("Required BVH joint not found: " + name); + } + + PVector position(PVector point) { + PVector root = parser.getBones().get(0).absPos; + return new PVector(-(point.x - root.x) * 1.55, (85 - point.y) * 1.55, + -(point.z - root.z) * 1.55); + } +} diff --git a/motion_ribbons/README.md b/motion_ribbons/README.md new file mode 100644 index 0000000..340cbf1 --- /dev/null +++ b/motion_ribbons/README.md @@ -0,0 +1,19 @@ +# Motion ribbons + +![Actual Processing render at 16 seconds](../docs/images/motion-ribbons.png) + +Open `motion_ribbons.pde` in Processing 4.5.6 and press Run. Keep the full repository together so the sketch can load the shared files from `../p5f_sample/data/`. No additional Processing library is required. For export, copy those three BVH files into this folder's `data/` directory first. + +The CPU caches five joint trajectories per dancer and samples a 3.2-second history into reusable `PShape` triangle strips. The strip width faces the current camera. The vertex shader transports position plus age/edge attributes, and the fragment shader adds thin bright edges and a restrained cyan, coral, or ivory sheen. The topology is generated on the CPU because standard Processing `PShader` provides vertex and fragment stages, not a geometry stage. + +- `motion_ribbons.pde`: playback, camera, drawing, keyboard controls. +- `RibbonDancer.pde`: pose cache, interpolation, triangle-strip vertices. +- `data/ribbon.vert` / `data/ribbon.frag`: the two shader stages. +- `MotionData.pde`: legacy BVH library adapter; recenters root X/Z for the side-by-side display. +- `SketchRun.pde`: capture and validation helper; the visual algorithm does not depend on test mode. + +Change `historySeconds`, `steps`, or `halfWidth` in `RibbonDancer.pde` to change the trails. Change `ink` and the edge/sheen calculation to tune the material. At a motion loop boundary the trail starts fresh rather than connecting unrelated end/start poses. + +Space pauses; drag orbits; R resets; H toggles labels; S saves a screenshot. Deterministic CLI captures are described in the [root README](../README.md#development-and-verification). + +Only the new sketch/shader code is covered by [`LICENSE-new-examples`](../LICENSE-new-examples). The original BVH data and the parser JAR retain their existing copyright status. diff --git a/motion_ribbons/RibbonDancer.pde b/motion_ribbons/RibbonDancer.pde new file mode 100644 index 0000000..0af912b --- /dev/null +++ b/motion_ribbons/RibbonDancer.pde @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT; see ../LICENSE-new-examples +class RibbonDancer { + final MotionData motion; + final int steps = 76; + final float historySeconds = 3.2; + final String[] jointNames = { "RightWrist", "LeftWrist", "RightAnkle", "LeftAnkle", "Head" }; + final List ribbons = new ArrayList<>(); + final PVector[][] history = new PVector[jointNames.length][steps]; + final BvhBone[] joints = new BvhBone[jointNames.length]; + final PVector[][] poses; + + RibbonDancer(String filename) { + motion = new MotionData(filename); + poses = new PVector[jointNames.length][motion.parser.getNbFrames()]; + for (int j = 0; j < jointNames.length; j++) { + joints[j] = motion.joint(jointNames[j]); + PShape strip = createShape(); + strip.beginShape(TRIANGLE_STRIP); + strip.noStroke(); + for (int k = 0; k < steps; k++) { + history[j][k] = new PVector(); + // Vertex colors transport age, edge coordinate and strand number. + strip.fill(255.0 * k / (steps - 1), 0, 255.0 * j / jointNames.length); + strip.vertex(0, 0, 0); + strip.fill(255.0 * k / (steps - 1), 255, 255.0 * j / jointNames.length); + strip.vertex(0, 0, 0); + } + strip.endShape(); + ribbons.add(strip); + } + // Cache only these five joints once. Drawing never reparses old poses. + for (int frame = 0; frame < motion.parser.getNbFrames(); frame++) { + motion.parser.moveFrameTo(frame); + motion.parser.update(); + for (int j = 0; j < joints.length; j++) poses[j][frame] = motion.position(joints[j].absPos); + } + } + + void update(float seconds, PVector viewDirection) { + // Resample a fixed time window: trails are identical at any frame rate. + // Interpolate the cached positions; shapes and arrays stay allocated. + float duration = motion.parser.getNbFrames() * motion.parser.getFrameTime(); + float loopStart = floor(seconds / duration) * duration; + for (int k = 0; k < steps; k++) { + float sampleTime = max(loopStart, seconds - historySeconds * (1 - float(k) / (steps - 1))); + float frame = (sampleTime - loopStart) / motion.parser.getFrameTime(); + int a = min(floor(frame), poses[0].length - 1); + int b = min(a + 1, poses[0].length - 1); + for (int j = 0; j < joints.length; j++) { + history[j][k].set(poses[j][a]); + history[j][k].lerp(poses[j][b], frame - a); + } + } + for (int j = 0; j < joints.length; j++) { + PShape strip = ribbons.get(j); + for (int k = 0; k < steps; k++) { + PVector tangent = PVector.sub(history[j][min(k + 1, steps - 1)], history[j][max(k - 1, 0)]); + PVector side = tangent.cross(viewDirection); + if (side.magSq() < 0.001) side.set(1, 0, 0); + side.normalize(); + float life = float(k) / (steps - 1); + float halfWidth = (j == 4 ? 2.7 : 6.5) * sin(PI * life) + 0.12; + side.mult(halfWidth); + PVector point = history[j][k]; + strip.setVertex(k * 2, point.x - side.x, point.y - side.y, point.z - side.z); + strip.setVertex(k * 2 + 1, point.x + side.x, point.y + side.y, point.z + side.z); + } + } + motion.update(seconds); + } +} diff --git a/motion_ribbons/SketchRun.pde b/motion_ribbons/SketchRun.pde new file mode 100644 index 0000000..fedc08e --- /dev/null +++ b/motion_ribbons/SketchRun.pde @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT; see ../LICENSE-new-examples +import java.nio.IntBuffer; +import processing.opengl.PGL; +import processing.opengl.PShader; + +// Automated runs retain an OpenGL window, but suppress its focus request. +protected PSurface initSurface() { + PSurface result = super.initSurface(); + if (args != null) for (String arg : args) { + if (arg.equals("--smoke-test") || arg.startsWith("--capture=")) { + ((com.jogamp.newt.opengl.GLWindow) result.getNative()).setFocusAction(() -> true); + } + } + return result; +} + +class SketchRun { + boolean automated = false; + boolean paused = false; + String capturePath; + float captureTime = 8; + float elapsed = 0; + int previousMillis = millis(); + final int captureFrame = 12; + + SketchRun() { + if (args != null) for (String arg : args) { + if (arg.equals("--smoke-test")) automated = true; + if (arg.startsWith("--capture=")) { + capturePath = arg.substring("--capture=".length()); + if (!new java.io.File(capturePath).isAbsolute()) { + throw new IllegalArgumentException("--capture requires an absolute output path"); + } + automated = true; + } + if (arg.startsWith("--time=")) captureTime = Float.parseFloat(arg.substring(7)); + } + if (!Float.isFinite(captureTime) || captureTime < 0 || captureTime > 3600) { + throw new IllegalArgumentException("--time must be finite, between 0 and 3600 seconds"); + } + } + + float seconds() { + int now = millis(); + if (!paused) elapsed += min((now - previousMillis) / 1000.0, 0.1); + previousMillis = now; + return automated ? max(0, captureTime + (frameCount - captureFrame) / 60.0) : elapsed; + } + + void togglePause() { paused = !paused; } + void reset() { elapsed = 0; paused = false; previousMillis = millis(); } + + PShader checkedShader(String fragment, String vertex) { + try { + if (!new java.io.File(dataPath(fragment)).isFile() || + (vertex != null && !new java.io.File(dataPath(vertex)).isFile())) { + throw new IllegalStateException("A required shader file is missing"); + } + PShader program = vertex == null ? loadShader(fragment) : loadShader(fragment, vertex); + // Processing can print a shader error and continue. Make that a test failure. + program.init(); + PGL gl = beginPGL(); + IntBuffer linked = IntBuffer.allocate(1); + gl.getProgramiv(program.glProgram, PGL.LINK_STATUS, linked); + String log = gl.getProgramInfoLog(program.glProgram); + endPGL(); + if (linked.get(0) != 1) throw new IllegalStateException("Shader did not link: " + log); + return program; + } catch (RuntimeException failure) { + System.err.println("SHADER_ERROR: " + failure.getMessage()); + // JOGL shutdown hooks can deadlock when compilation fails on its draw + // thread. Test failures exit immediately; normal runs use exit(). + if (automated) Runtime.getRuntime().halt(1); + throw failure; + } + } + + void finishFrame(String name, int motions) { + if (!automated || frameCount < captureFrame) return; + loadPixels(); + int litPixels = 0; + for (int pixel : pixels) { + if (max((pixel >> 16) & 255, max((pixel >> 8) & 255, pixel & 255)) > 65) litPixels++; + } + if (litPixels < 600) { + System.err.println("SMOKE_TEST_FAILED: rendered frame is unexpectedly empty"); + Runtime.getRuntime().halt(1); + } + if (capturePath != null) { + save(capturePath); + java.io.File output = new java.io.File(capturePath); + if (!output.isFile() || output.length() < 1000) throw new IllegalStateException("Capture was not saved"); + } + println("SMOKE_TEST_OK sketch=" + name + " frames=" + frameCount + + " motions=" + motions + " litPixels=" + litPixels + " focused=" + focused); + exit(); + } +} diff --git a/motion_ribbons/code/BVHParser.jar b/motion_ribbons/code/BVHParser.jar new file mode 100644 index 0000000..45d8488 Binary files /dev/null and b/motion_ribbons/code/BVHParser.jar differ diff --git a/motion_ribbons/data/ribbon.frag b/motion_ribbons/data/ribbon.frag new file mode 100644 index 0000000..613af70 --- /dev/null +++ b/motion_ribbons/data/ribbon.frag @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT +#ifdef GL_ES +precision mediump float; +precision mediump int; +#endif +uniform vec3 ink; +uniform float time; +varying vec4 ribbon; +varying vec3 position; + +void main() { + float age = ribbon.r; + float across = ribbon.g; + float edge = abs(across * 2.0 - 1.0); + float filament = smoothstep(0.72, 0.99, edge); + float sheen = pow(max(0.0, sin(across * 3.14159 + position.y * 0.008)), 12.0); + float pulse = 0.86 + 0.14 * sin(age * 24.0 - time * 2.0 + ribbon.b * 6.0); + vec3 body = ink * (0.22 + 0.52 * age) * pulse; + body += mix(ink, vec3(1.0), 0.6) * (filament * 0.7 + sheen * 0.28); + float fade = smoothstep(0.0, 0.18, age); + gl_FragColor = vec4(body * fade, 1.0); +} diff --git a/motion_ribbons/data/ribbon.vert b/motion_ribbons/data/ribbon.vert new file mode 100644 index 0000000..7377570 --- /dev/null +++ b/motion_ribbons/data/ribbon.vert @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT +#define PROCESSING_COLOR_SHADER +uniform mat4 transform; +attribute vec4 vertex; +attribute vec4 color; +varying vec4 ribbon; +varying vec3 position; + +void main() { + ribbon = color; + position = vertex.xyz; + gl_Position = transform * vertex; +} diff --git a/motion_ribbons/motion_ribbons.pde b/motion_ribbons/motion_ribbons.pde new file mode 100644 index 0000000..e5dc993 --- /dev/null +++ b/motion_ribbons/motion_ribbons.pde @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT; see ../LICENSE-new-examples +import com.rhizomatiks.bvh.BvhBone; +import com.rhizomatiks.bvh.BvhParser; +import java.util.List; +import java.util.ArrayList; + +final List dancers = new ArrayList<>(); +PShader ribbonShader; +SketchRun run; +boolean showHelp = true; +float orbit = 0.15; + +void settings() { + size(1280, 800, P3D); + pixelDensity(1); + smooth(4); +} + +void setup() { + surface.setTitle("Perfume / motion ribbons"); + surface.setResizable(true); + frameRate(60); + run = new SketchRun(); + showHelp = !run.automated; + ribbonShader = run.checkedShader("ribbon.frag", "ribbon.vert"); + String[] files = { "A_test.bvh", "B_test.bvh", "C_test.bvh" }; + for (int i = 0; i < files.length; i++) dancers.add(new RibbonDancer(files[i])); + textFont(createFont("SansSerif", 13)); +} + +void draw() { + float time = run.seconds(); + background(7, 12, 21); + float distance = max(580, 870.0 * height / width); + perspective(PI / 3, float(width) / height, 1, max(3000, distance + 1500)); + camera(distance * sin(orbit), -125, distance * cos(orbit), 0, -10, 0, 0, 1, 0); + drawStage(); + float[][] palette = { {0.30, 0.90, 0.87}, {1.0, 0.46, 0.37}, {0.94, 0.88, 0.72} }; + ribbonShader.set("time", time); + for (int i = 0; i < dancers.size(); i++) { + RibbonDancer dancer = dancers.get(i); + dancer.update(time, new PVector(sin(orbit), -115 / distance, cos(orbit)).normalize()); + pushMatrix(); + translate((i - 1) * 235, 0, 0); + ribbonShader.set("ink", palette[i][0], palette[i][1], palette[i][2]); + shader(ribbonShader); + for (PShape ribbon : dancer.ribbons) shape(ribbon); + resetShader(); + stroke(220, 232, 235, 95); + strokeWeight(1); + noFill(); + beginShape(LINES); + for (BvhBone bone : dancer.motion.parser.getBones()) { + if (bone.getParent() != null) { + PVector a = dancer.motion.position(bone.getParent().absPos); + PVector b = dancer.motion.position(bone.absPos); + vertex(a.x, a.y, a.z); + vertex(b.x, b.y, b.z); + } + } + endShape(); + popMatrix(); + } + if (showHelp) drawLabels(); + run.finishFrame("motion_ribbons", dancers.size()); +} + +void drawStage() { + stroke(24, 37, 49); + strokeWeight(1); + for (int x = -500; x <= 500; x += 50) line(x, 144, -220, x, 144, 220); + for (int z = -220; z <= 220; z += 55) line(-500, 144, z, 500, 144, z); +} + +void drawLabels() { + hint(DISABLE_DEPTH_TEST); + camera(); + fill(228, 237, 239); + text("PERFUME / MOTION RIBBONS", 38, 43); + fill(126, 145, 160); + text("01 / BVH > triangle strip > vertex + fragment shader", 38, 66); + text("SPACE pause drag orbit R reset H labels S screenshot", 38, height - 32); + hint(ENABLE_DEPTH_TEST); +} + +void mouseDragged() { orbit += (mouseX - pmouseX) * 0.006; } +void keyPressed() { + if (key == ' ') run.togglePause(); + if (key == 'r' || key == 'R') { run.reset(); orbit = 0.15; } + if (key == 'h' || key == 'H') showHelp = !showHelp; + if (key == 's' || key == 'S') saveFrame("captures/ribbons-####.png"); +} diff --git a/p5f_sample/PBvh.pde b/p5f_sample/PBvh.pde index 8c913b3..441dfde 100644 --- a/p5f_sample/PBvh.pde +++ b/p5f_sample/PBvh.pde @@ -1,35 +1,58 @@ +// The original parser is unchanged. Retained shapes are updated in place. class PBvh { - final BvhParser parser; + final BvhParser parser = new BvhParser(); + final PShape bones; + final PShape joints; - PBvh(String[] data) { - if (data == null) { - throw new IllegalArgumentException("BVH data could not be loaded"); - } - - parser = new BvhParser(); + PBvh(String[] data, int ink) { + if (data == null) throw new IllegalArgumentException("BVH data could not be loaded"); parser.parse(data); + bones = createShape(); + bones.beginShape(LINES); + bones.noFill(); + bones.stroke(ink, 150); + bones.strokeWeight(1.3); + joints = createShape(); + joints.beginShape(POINTS); + joints.stroke(ink); + joints.strokeWeight(5); + for (BvhBone bone : parser.getBones()) { + joints.vertex(0, 0, 0); + if (bone.getParent() != null) { bones.vertex(0, 0, 0); bones.vertex(0, 0, 0); } + if (!bone.hasChildren()) { + joints.vertex(0, 0, 0); + bones.vertex(0, 0, 0); + bones.vertex(0, 0, 0); + } + } + bones.endShape(); + joints.endShape(); } - void update(int ms) { - parser.moveMsTo(ms); + void update(float seconds) { + parser.moveMsTo(round(seconds * 1000)); parser.update(); - } - - void draw() { - fill(255); - + PVector root = parser.getBones().get(0).absPos; + int jointIndex = 0; + int boneIndex = 0; for (BvhBone bone : parser.getBones()) { - pushMatrix(); - translate(bone.absPos.x, bone.absPos.y, bone.absPos.z); - ellipse(0, 0, 2, 2); - popMatrix(); - + PVector position = stagePosition(bone.absPos, root); + joints.setVertex(jointIndex++, position); + if (bone.getParent() != null) { + bones.setVertex(boneIndex++, stagePosition(bone.getParent().absPos, root)); + bones.setVertex(boneIndex++, position); + } if (!bone.hasChildren()) { - pushMatrix(); - translate(bone.absEndPos.x, bone.absEndPos.y, bone.absEndPos.z); - ellipse(0, 0, 10, 10); - popMatrix(); + PVector end = stagePosition(bone.absEndPos, root); + joints.setVertex(jointIndex++, end); + bones.setVertex(boneIndex++, position); + bones.setVertex(boneIndex++, end); } } } + + PVector stagePosition(PVector point, PVector root) { + return new PVector(-(point.x - root.x) * 1.55, (85 - point.y) * 1.55, + -(point.z - root.z) * 1.55); + } } diff --git a/p5f_sample/SketchRun.pde b/p5f_sample/SketchRun.pde new file mode 100644 index 0000000..fedc08e --- /dev/null +++ b/p5f_sample/SketchRun.pde @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT; see ../LICENSE-new-examples +import java.nio.IntBuffer; +import processing.opengl.PGL; +import processing.opengl.PShader; + +// Automated runs retain an OpenGL window, but suppress its focus request. +protected PSurface initSurface() { + PSurface result = super.initSurface(); + if (args != null) for (String arg : args) { + if (arg.equals("--smoke-test") || arg.startsWith("--capture=")) { + ((com.jogamp.newt.opengl.GLWindow) result.getNative()).setFocusAction(() -> true); + } + } + return result; +} + +class SketchRun { + boolean automated = false; + boolean paused = false; + String capturePath; + float captureTime = 8; + float elapsed = 0; + int previousMillis = millis(); + final int captureFrame = 12; + + SketchRun() { + if (args != null) for (String arg : args) { + if (arg.equals("--smoke-test")) automated = true; + if (arg.startsWith("--capture=")) { + capturePath = arg.substring("--capture=".length()); + if (!new java.io.File(capturePath).isAbsolute()) { + throw new IllegalArgumentException("--capture requires an absolute output path"); + } + automated = true; + } + if (arg.startsWith("--time=")) captureTime = Float.parseFloat(arg.substring(7)); + } + if (!Float.isFinite(captureTime) || captureTime < 0 || captureTime > 3600) { + throw new IllegalArgumentException("--time must be finite, between 0 and 3600 seconds"); + } + } + + float seconds() { + int now = millis(); + if (!paused) elapsed += min((now - previousMillis) / 1000.0, 0.1); + previousMillis = now; + return automated ? max(0, captureTime + (frameCount - captureFrame) / 60.0) : elapsed; + } + + void togglePause() { paused = !paused; } + void reset() { elapsed = 0; paused = false; previousMillis = millis(); } + + PShader checkedShader(String fragment, String vertex) { + try { + if (!new java.io.File(dataPath(fragment)).isFile() || + (vertex != null && !new java.io.File(dataPath(vertex)).isFile())) { + throw new IllegalStateException("A required shader file is missing"); + } + PShader program = vertex == null ? loadShader(fragment) : loadShader(fragment, vertex); + // Processing can print a shader error and continue. Make that a test failure. + program.init(); + PGL gl = beginPGL(); + IntBuffer linked = IntBuffer.allocate(1); + gl.getProgramiv(program.glProgram, PGL.LINK_STATUS, linked); + String log = gl.getProgramInfoLog(program.glProgram); + endPGL(); + if (linked.get(0) != 1) throw new IllegalStateException("Shader did not link: " + log); + return program; + } catch (RuntimeException failure) { + System.err.println("SHADER_ERROR: " + failure.getMessage()); + // JOGL shutdown hooks can deadlock when compilation fails on its draw + // thread. Test failures exit immediately; normal runs use exit(). + if (automated) Runtime.getRuntime().halt(1); + throw failure; + } + } + + void finishFrame(String name, int motions) { + if (!automated || frameCount < captureFrame) return; + loadPixels(); + int litPixels = 0; + for (int pixel : pixels) { + if (max((pixel >> 16) & 255, max((pixel >> 8) & 255, pixel & 255)) > 65) litPixels++; + } + if (litPixels < 600) { + System.err.println("SMOKE_TEST_FAILED: rendered frame is unexpectedly empty"); + Runtime.getRuntime().halt(1); + } + if (capturePath != null) { + save(capturePath); + java.io.File output = new java.io.File(capturePath); + if (!output.isFile() || output.length() < 1000) throw new IllegalStateException("Capture was not saved"); + } + println("SMOKE_TEST_OK sketch=" + name + " frames=" + frameCount + + " motions=" + motions + " litPixels=" + litPixels + " focused=" + focused); + exit(); + } +} diff --git a/p5f_sample/p5f_sample.pde b/p5f_sample/p5f_sample.pde index 74b073d..86f5f61 100644 --- a/p5f_sample/p5f_sample.pde +++ b/p5f_sample/p5f_sample.pde @@ -1,72 +1,67 @@ import com.rhizomatiks.bvh.BvhBone; import com.rhizomatiks.bvh.BvhParser; +import java.util.List; +import java.util.ArrayList; -PBvh bvh1; -PBvh bvh2; -PBvh bvh3; -boolean smokeTest; +final List dancers = new ArrayList<>(); +SketchRun run; +boolean showHelp = true; +float orbit = 0; void settings() { - size(1280, 720, P3D); + size(1280, 800, P3D); + pixelDensity(1); + smooth(4); } void setup() { - background(0); - noStroke(); - frameRate(30); - - smokeTest = false; - if (args != null) { - for (String arg : args) { - smokeTest |= "--smoke-test".equals(arg); - } - } - - bvh1 = new PBvh(loadStrings("A_test.bvh")); - bvh2 = new PBvh(loadStrings("B_test.bvh")); - bvh3 = new PBvh(loadStrings("C_test.bvh")); + surface.setTitle("Perfume / point skeletons"); + surface.setResizable(true); + frameRate(60); + run = new SketchRun(); + showHelp = !run.automated; + int[] palette = { color(110, 224, 219), color(255, 142, 124), color(238, 232, 210) }; + String[] files = { "A_test.bvh", "B_test.bvh", "C_test.bvh" }; + for (int i = 0; i < files.length; i++) dancers.add(new PBvh(loadStrings(files[i]), palette[i])); + textFont(createFont("SansSerif", 13)); } void draw() { - background(0); - - // Camera - float orbitCos = cos(millis() / 5000.0f); - float orbitSin = sin(millis() / 5000.0f); - camera( - width / 4.0f + width / 4.0f * orbitCos + 200, - height / 2.0f - 100, - 550 + 150 * orbitSin, - width / 2.0f, - height / 2.0f, - -400, - 0, - 1, - 0 - ); - - // Ground - fill(255); - stroke(127); - line(width / 2.0f, height / 2.0f, -30, width / 2.0f, height / 2.0f, 30); - line(width / 2.0f - 30, height / 2.0f, 0, width / 2.0f + 30, height / 2.0f, 0); - stroke(255); - - pushMatrix(); - translate(width / 2.0f, height / 2.0f - 10, 0); - scale(-1, -1, -1); - - // Models - bvh1.update(millis()); - bvh2.update(millis()); - bvh3.update(millis()); - bvh1.draw(); - bvh2.draw(); - bvh3.draw(); - popMatrix(); - - if (smokeTest && frameCount >= 10) { - println("SMOKE_TEST_OK frames=" + frameCount + " motions=3"); - exit(); + float time = run.seconds(); + background(8, 13, 23); + float distance = max(650, 900.0 * height / width); + perspective(PI / 3, float(width) / height, 1, max(3000, distance + 1500)); + camera(distance * sin(orbit), -90, distance * cos(orbit), 0, -5, 0, 0, 1, 0); + stroke(39, 52, 64); + strokeWeight(1); + for (int x = -450; x <= 450; x += 50) line(x, 145, -250, x, 145, 250); + for (int z = -250; z <= 250; z += 50) line(-450, 145, z, 450, 145, z); + for (int i = 0; i < dancers.size(); i++) { + PBvh dancer = dancers.get(i); + dancer.update(time); + pushMatrix(); + translate((i - 1) * 240, 0, 0); + shape(dancer.bones); + shape(dancer.joints); + popMatrix(); + } + if (showHelp) { + hint(DISABLE_DEPTH_TEST); + camera(); + fill(225, 233, 236); + text("PERFUME / POINT SKELETONS", 38, 43); + fill(126, 145, 160); + text("PShape + PVector / three original BVH recordings", 38, 66); + text("SPACE pause drag orbit R reset H labels S screenshot", 38, height - 32); + hint(ENABLE_DEPTH_TEST); } + run.finishFrame("p5f_sample", 3); +} + +void mouseDragged() { orbit += (mouseX - pmouseX) * 0.006; } +void keyPressed() { + if (key == ' ') run.togglePause(); + if (key == 'r' || key == 'R') { run.reset(); orbit = 0; } + if (key == 'h' || key == 'H') showHelp = !showHelp; + if (key == 's' || key == 'S') saveFrame("captures/points-####.png"); } diff --git a/scripts/verify-sketches.sh b/scripts/verify-sketches.sh new file mode 100755 index 0000000..30f06a1 --- /dev/null +++ b/scripts/verify-sketches.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Daito Manabe +# SPDX-License-Identifier: MIT; see ../LICENSE-new-examples +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 /path/to/Processing /path/to/test-output" >&2 + exit 2 +fi +processing_bin=$1 +mkdir -p "$2" +test_output=$(cd "$2" && pwd) +repo_root=$(cd "$(dirname "$0")/.." && pwd) + +run_processing() { + # A fresh process group bounds the launcher and its child JVM on Linux/macOS. + perl -MPOSIX -e ' + my $limit = shift @ARGV; + my $child = fork(); + defined $child or die "Cannot fork: $!"; + if (!$child) { POSIX::setsid(); exec @ARGV; die "Cannot launch: $!"; } + my $status; + eval { + local $SIG{ALRM} = sub { die "Processing timed out\n"; }; + alarm $limit; + waitpid($child, 0); + $status = $?; + alarm 0; + }; + if ($@) { + warn $@; + kill "TERM", -$child; + sleep 1; + kill "KILL", -$child; + waitpid($child, 0); + exit 124; + } + exit(($status & 127) ? 128 + ($status & 127) : ($status >> 8)); + ' 60 "$processing_bin" "$@" +} + +for sketch in p5f_sample motion_ribbons motion_field; do + cmp "$repo_root/p5f_sample/code/BVHParser.jar" "$repo_root/$sketch/code/BVHParser.jar" + cmp "$repo_root/p5f_sample/SketchRun.pde" "$repo_root/$sketch/SketchRun.pde" + first_frame= + for seconds in 8 16; do + # Each run compiles before rendering. New output directories avoid --force. + run_dir=$(mktemp -d "$test_output/$sketch-$seconds.XXXXXX") + run_processing cli --sketch="$repo_root/$sketch" --output="$run_dir/build" \ + --run --smoke-test --time="$seconds" --capture="$run_dir/frame.png" 2>&1 | tee "$run_dir/runtime.log" + grep -q "SMOKE_TEST_OK sketch=$sketch" "$run_dir/runtime.log" + test -s "$run_dir/frame.png" + if [[ -n "$first_frame" ]] && cmp -s "$first_frame" "$run_dir/frame.png"; then + echo "$sketch did not change between the two motion timestamps" >&2 + exit 1 + fi + first_frame="$run_dir/frame.png" + done +done +cmp "$repo_root/motion_ribbons/MotionData.pde" "$repo_root/motion_field/MotionData.pde" + +# Shader failure must never pass via Processing's fallback renderer. +for failure in invalid missing; do + negative_dir=$(mktemp -d "$test_output/shader-$failure.XXXXXX") + cp -R "$repo_root/motion_field" "$negative_dir/motion_field" + if [[ "$failure" == invalid ]]; then + cp "$repo_root/tests/shaders/invalid.frag" "$negative_dir/motion_field/data/field.frag" + else + mv "$negative_dir/motion_field/data/field.frag" "$negative_dir/field.frag.disabled" + fi + if run_processing cli --sketch="$negative_dir/motion_field" --output="$negative_dir/build" \ + --run --smoke-test > "$negative_dir/runtime.log" 2>&1; then + # Processing's launcher may return zero even when the sketch exits nonzero. + echo "Launcher returned zero; checking explicit shader failure evidence." + fi + cat "$negative_dir/runtime.log" + grep -a -q 'SHADER_ERROR:' "$negative_dir/runtime.log" + if grep -a -q 'Processing timed out' "$negative_dir/runtime.log"; then exit 1; fi + if grep -q 'SMOKE_TEST_OK' "$negative_dir/runtime.log"; then + echo "A $failure shader incorrectly passed the render test" >&2 + exit 1 + fi +done +echo "All three sketches rendered at two timestamps; shader failure checks passed." diff --git a/tests/shaders/invalid.frag b/tests/shaders/invalid.frag new file mode 100644 index 0000000..0d1934a --- /dev/null +++ b/tests/shaders/invalid.frag @@ -0,0 +1,4 @@ +// Copyright (c) 2026 Daito Manabe +// SPDX-License-Identifier: MIT +// Deliberately malformed GLSL. The runtime test must reject this file. +void main() { invalid_shader_must_not_render; }