Skip to content

Add p5.strands support for randomGaussian()#8800

Merged
perminder-17 merged 9 commits into
processing:dev-2.0from
harshiltewari2004:strands-random-gaussian
May 31, 2026
Merged

Add p5.strands support for randomGaussian()#8800
perminder-17 merged 9 commits into
processing:dev-2.0from
harshiltewari2004:strands-random-gaussian

Conversation

@harshiltewari2004
Copy link
Copy Markdown
Contributor

Resolves #8775

Changes

Adds randomGaussian() support for p5.strands, following @davepagurek's guidance in #8775. The implementation is a JS-side wrapper over the existing random() strands function, applying a Box-Muller transform to two uniform samples to produce a normal-distributed sample.

  • New augmentFn entry in src/strands/strands_api.js next to the existing random() registration.
  • Falls through to the original p5 randomGaussian() when called outside a strands shader context.
  • Inside a strands context: builds Box-Muller via two this.random() calls + chained node math.

Implementation note

Uses this.x() rather than fn.x() per @davepagurek's note in the issue thread.

Testing

No unit tests included — there's no existing test/unit/strands/ directory and no clear precedent for unit-testing strands functions (which produce IR nodes rather than directly-testable values). Happy to add tests in whichever direction the maintainers prefer.

Local npm test shows the same 4 flaky visual regression test failures (test/unit/visual/cases/typography.js) that I observed on dev-2.0 before my changes — unrelated to this PR.

PR Checklist

  • npm run lint passes
  • [Inline reference] is included / updated
  • [Unit tests] are included / updated

@perminder-17 perminder-17 self-requested a review May 20, 2026 12:02
Comment thread src/strands/strands_api.js Outdated
Comment on lines +419 to +431
augmentFn(fn, p5, 'randomGaussian', function(...args){
if(!strandsContext.active){
return originalRandomGaussian.apply(this, args);
}
const mean = args.length >= 1 ? args[0] : 0;
const stdDev = args.length>=2 ? args[1] : 1;

const u1 = this.random();
const u2 = this.random();
const z = this.sqrt(this.log(u1).mult(-2)).mult(this.cos(u2.mult(2*Math.PI)));

return z.mult(p5.strandsNode(stdDev)).add(p5.strandsNode(mean));
});
Copy link
Copy Markdown
Collaborator

@perminder-17 perminder-17 May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, thanks for the work @harshiltewari2004 , just one concern I have: are you defining randomGaussian inside the random function intentionally? Do you think it should be outside, otherwise randomGaussian won't exist until the user calls random() at least once, and it'll get redefined on every random() call.

Also, can you ad tests for this implementation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks @perminder-17 — fixed in the latest commit. The randomGaussian augmentation is now a sibling of random, not nested inside it.

@harshiltewari2004
Copy link
Copy Markdown
Contributor Author

Happy to add tests @perminder-17 — I noticed there's no test/unit/strands/ directory yet, so I held off. Should I establish that pattern here, or is there an existing test approach for strands I missed?

@perminder-17
Copy link
Copy Markdown
Collaborator

off

You can go with the visual tests, that would make more sense. Thanks for the update :)

@harshiltewari2004
Copy link
Copy Markdown
Contributor Author

Thanks @perminder-17! Here's the plan — would appreciate a thumbs-up before I write the tests.

Location: inside the existing visualSuite('p5.strands', ...) in both test/unit/visual/cases/webgl.js and test/unit/visual/cases/webgpu.js. Mirrors the random() colors a basic shader pattern (webgl L1119 / webgpu L329). No new file or suite.

Tests (two per file, four total):

  1. randomGaussian() colors a basic shader — direct analogue of the existing random() shader-coloring test. randomSeed(12), then randomGaussian(0.5, 0.1) per pixel mapped to a grayscale color channel. Mean of 0.5 + small sd keeps ~99.7% of samples in [0.2, 0.8] (3σ), so out-of-range tails are rare enough not to dominate the snapshot — no clamping needed.

  2. randomGaussian() in a fragment loop averages to the mean — analogue of the existing random() in a fragment loop averages to gray test (webgpu L346). randomSeed(7), sum 20 samples of randomGaussian(0.5, 0.2) per pixel, divide by 20. Averaging shrinks the spread by a factor of √20, so the canvas should appear nearly-uniform gray near 0.5. A single-sample test can't catch a wrong distribution mean; this one can.

WebGPU versions will be async with await createCanvas / await screenshot(); WebGL versions sync — per existing convention.

Reference screenshots will be generated on first run, sanity-checked (test 1: Gaussian noise around mid-gray; test 2: near-uniform gray), then committed alongside the test code.

Does this look reasonable? Happy to adjust means/sd/sample counts, or add a third test for a different property.

@perminder-17
Copy link
Copy Markdown
Collaborator

  • randomGaussian() colors a basic shader — direct analogue of the existing random() shader-coloring test. randomSeed(12), then randomGaussian(0.5, 0.1) per pixel mapped to a grayscale color channel. Mean of 0.5 + small sd keeps ~99.7% of samples in [0.2, 0.8] (3σ), so out-of-range tails are rare enough not to dominate the snapshot — no clamping needed.

  • randomGaussian() in a fragment loop averages to the mean — analogue of the existing random() in a fragment loop averages to gray test (webgpu L346). randomSeed(7), sum 20 samples of randomGaussian(0.5, 0.2) per pixel, divide by 20. Averaging shrinks the spread by a factor of √20, so the canvas should appear nearly-uniform gray near 0.5. A single-sample test can't catch a wrong distribution mean; this one can.

Yeah, everything sounds good to me. You can go with adding that. Thanks

The averaging test (loop of 20 samples) revealed that randomGaussian was returning values from N(0.5, 1.0) instead of N(0.5, stdDev), causing heavy clipping visible as outlier pixels throughout the canvas. Root cause: unnecessary p5.strandsNode() wrapping around the stdDev and mean scalars in the final scale-and-shift expression. Removing the wrapping applies the multiplication correctly, mirroring how scalar literals (-2, 2*PI) are already passed to .mult() elsewhere in the same body.

Tests added in both webgl and webgpu suites:
- randomGaussian() colors a basic shader (single-sample distribution)
- randomGaussian() in a fragment loop averages to the mean (variance reduction)
@harshiltewari2004
Copy link
Copy Markdown
Contributor Author

Tests added per discussion — randomGaussian() colors a basic shader and randomGaussian() in a fragment loop averages to the mean, in both webgl and webgpu visual suites. Following the existing patterns from the random() tests in the same files.
One thing worth flagging: writing the averaging test surfaced a bug in the implementation. randomGaussian(0.5, 0.2) was producing samples from N(0.5, 1.0) — the stdDev argument wasn't being applied. Traced it to unnecessary p5.strandsNode() wrapping around the scalar in the final scale-and-shift line. Removed the wrapping (mirroring how -2 and 2*PI are passed raw to .mult() elsewhere in the same expression), and Test 2 now shows the expected variance-reduced output.
Small caveat on Test 2's snapshot: there are a few scattered outlier pixels that don't appear in the analogous random() in a fragment loop averages to gray reference. The math suggests they aren't simple Box-Muller tail behavior (a single extreme sample averaged with 19 normal ones can't push the result outside [0,1] at this stdDev). Could indicate non-independence in strands' random() at certain seeds, but I haven't traced it conclusively. The snapshot is deterministic and stable across runs, so the test catches regressions either way — flagging it here for visibility in case it points at something worth a follow-up.

Comment thread src/strands/strands_api.js Outdated

const u1 = this.random();
const u2 = this.random();
const z = this.sqrt(this.log(u1).mult(-2)).mult(this.cos(u2.mult(2*Math.PI)));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we are doing this.log(u1) we need to ensure that the value is finite, but when u1 value would be 0, then this.log(u1) would be -infinity, do you think we can handle this case for u1?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — yes, this is exactly it. When random() returns a value at or very near 0, log(u1) goes to -∞ and the sample blows up. I actually saw the symptom while writing the visual tests: the loop-averaging snapshot had a few scattered pure-white/black outlier pixels that the math said shouldn't be possible from normal Box-Muller tails. I flagged it in the PR description as a possible non-independence issue, but your read on log(0) is the real mechanism.
I'll clamp u1 away from 0 before the log — something like max(random(), epsilon) — which keeps it finite and should clear up those outliers. Will also regenerate the Test 2 snapshot and confirm the outliers are gone. Does that approach sound right to you?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, approach sounds good to me!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — clamped u1 with this.max(this.random(), 1e-6) before the log, so when random() returns at or near 0, log(u1) stays finite instead of going to -∞. That caps the sample magnitude at roughly 5σ. I regenerated the loop-averaging snapshots and the outlier pixels I'd flagged in the PR description are now gone — Test 2 shows the expected uniform mid-gray. Thanks for catching the root cause.

harshiltewari2004 and others added 2 commits May 31, 2026 21:29
When random() returns a value at or near 0, log(u1) goes to -infinity and the Box-Muller sample explodes, producing extreme outlier pixels. Clamping u1 with max(random(), 1e-6) bounds log(u1) at ~-13.8, capping the sample magnitude at ~5 sigma. Regenerated the loop-averaging visual snapshots, which now show the expected uniform-gray distribution with the outliers gone.
Comment thread src/strands/strands_api.js Outdated
Copy link
Copy Markdown
Collaborator

@perminder-17 perminder-17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me!

@perminder-17 perminder-17 merged commit 5c31a02 into processing:dev-2.0 May 31, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants