diff --git a/.github/ISSUE_TEMPLATE/1-p5.js-2.0-bug-report.yml b/.github/ISSUE_TEMPLATE/1-p5.js-2.0-bug-report.yml index bb04352fa9..b68ef85bfc 100644 --- a/.github/ISSUE_TEMPLATE/1-p5.js-2.0-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/1-p5.js-2.0-bug-report.yml @@ -1,18 +1,16 @@ -name: πŸ“ƒ p5.js 2.0 Beta Bug Report -description: This template is for submitting a bug report for bugs found in the p5.js 2.0 beta releases. -title: "[p5.js 2.0 Beta Bug Report]: " -labels: [p5.js 2.0] +name: πŸ“ƒ p5.js 2.0+ Bug Report +description: This template is for submitting a bug report for bugs found in p5v2! +title: "[p5.js 2.0+ Bug Report]: " +labels: [p5.js 2.0+] body: - type: markdown attributes: value: | ### What falls under this category? - There has been many changes to p5.js in 2.0 that is currently released as beta versions. If you suspect there may be a bug, please follow the below steps before opening a bug report using this template: - - 1. There are some differences in behavior between p5.js 1.x and 2.0 beta, please check the changelog and/or [proposal list](https://github.com/orgs/processing/projects/21) to see if the difference in behavior is intended. If in doubt, feel free to open the issue anyway and ask. - 2. Breaking changes may still happen between beta versions, please make sure to include the full beta version number and use the latest beta release where possible. - 3. We are not considering any new proposal for p5.js 2.0 at this stage and if you would like to request new features, please use the "New feature request" issue template. - 4. The documentation and examples may be outdated at this stage while we work on updating them. + There has been many changes to p5.js in 2.0! You can try it in p5.js Editor by updating the version in "Settings," and reference is available [on the beta version of the site](https://beta.p5js.org/). If you suspect there may be a bug, please follow the below steps before opening a bug report using this template: + 1. There are some differences in behavior between p5.js 1.x and 2.0 beta, please check the changelog and/or [2.0 status board](https://github.com/orgs/processing/projects/21) to see if the difference in behavior is intended. If in doubt, feel free to open the issue anyway and ask. + 2. New proposals for future p5.js 2.x minor releases may be considered! Please use the "New feature request" issue template, or check the [2.0 status board](https://github.com/orgs/processing/projects/21) is there is already discussion abotu this proposal. + 3. Reports of errors or potential improvements in documentation and examples is expecially helpful, since many things have been updated from 1.x to 2.0! - type: checkboxes id: sub-area attributes: @@ -31,6 +29,8 @@ body: - label: Typography - label: Utilities - label: WebGL + - label: WebGPU + - label: p5.strands - label: Build process - label: Unit testing - label: Internationalization @@ -78,4 +78,4 @@ body: ```" validations: - required: true \ No newline at end of file + required: true diff --git a/.github/workflows/auto-close-issues.yml b/.github/workflows/auto-close-issues.yml index a2b3310aae..72ac018794 100644 --- a/.github/workflows/auto-close-issues.yml +++ b/.github/workflows/auto-close-issues.yml @@ -6,13 +6,18 @@ on: branches: - dev-2.0 +permissions: + contents: read + issues: write + pull-requests: read + jobs: close_issues: if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - name: Close linked issues on non-default branches - uses: processing/branch-pr-close-issue@v1 + uses: processing/branch-pr-close-issue@9fd7b409a12c677c5cdd8ff82c45600f790074e1 # v1 with: token: ${{ secrets.GITHUB_TOKEN }} branch: dev-2.0 diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml index ea1eccbee2..8f5091ec23 100644 --- a/.github/workflows/ci-lint.yml +++ b/.github/workflows/ci-lint.yml @@ -7,15 +7,19 @@ on: pull_request: branches: - '*' +permissions: + contents: read jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Use Node.js 22.x - uses: actions/setup-node@v1 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - name: Get node modules diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index e5ceb912c1..7f23015ba2 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -9,6 +9,9 @@ on: branches: - '*' +permissions: + contents: read + jobs: test: strategy: @@ -22,10 +25,12 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Use Node.js 22.x - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x @@ -59,7 +64,7 @@ jobs: CI: true - name: Upload Visual Test Report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: visual-test-report path: test/unit/visual/visual-report.html @@ -72,11 +77,6 @@ jobs: run: npm run test:types env: CI: true - - name: report test coverage - if: steps.test.outcome == 'success' - run: bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json - env: - CI: true - name: fail job if tests failed if: steps.test.outcome != 'success' run: exit 1 \ No newline at end of file diff --git a/.github/workflows/continuous-release.yml b/.github/workflows/continuous-release.yml new file mode 100644 index 0000000000..44114d0f49 --- /dev/null +++ b/.github/workflows/continuous-release.yml @@ -0,0 +1,55 @@ +name: Publish approved pull requests and latest commit to pkg.pr.new +on: + pull_request: + branches: + - 'dev-2.0' + push: + branches: + - 'dev-2.0' + tags: + - '!**' + +permissions: + pull-requests: write + issues: write + +jobs: + publish: + if: github.repository == 'processing/p5.js' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci + + - name: Build library + run: npm run build + + - name: Publish library + run: npx pkg-pr-new publish --no-template --json output.json --comment=off + + - name: Include PR info in output file + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const fs = require('fs'); + const output = JSON.parse(fs.readFileSync('output.json', 'utf8')); + output.workflow = { + pull_request: process.env.PR_NUMBER ? { + number: process.env.PR_NUMBER + } : null + }; + fs.writeFileSync('output.json', JSON.stringify(output)); + + - name: Upload output data + uses: actions/upload-artifact@v4 + with: + name: output.zip + path: output.json \ No newline at end of file diff --git a/.github/workflows/contributors-png.yml b/.github/workflows/contributors-png.yml index 79933b44a4..321b5ddfa5 100644 --- a/.github/workflows/contributors-png.yml +++ b/.github/workflows/contributors-png.yml @@ -5,15 +5,20 @@ on: paths: - '.all-contributorsrc' +permissions: + contents: read + jobs: build: if: github.ref == 'refs/heads/main' && github.repository == 'processing/p5.js' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 @@ -30,7 +35,7 @@ jobs: git checkout -- . - name: Create Pull Request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 with: commit-message: "Update contributors.png from .all-contributorsrc" branch: update-contributors-png diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 74e500b5e6..cc10da56c3 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -3,12 +3,13 @@ on: issues: types: [opened, edited] permissions: + contents: read issues: write jobs: triage: runs-on: ubuntu-latest steps: - - uses: github/issue-labeler@v3.2 + - uses: github/issue-labeler@98b5412841f6c4b0b3d9c29d53c13fad16bd7de2 # v3.2 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" configuration-path: .github/labeler.yml diff --git a/.github/workflows/release-workflow-v2.yml b/.github/workflows/release-workflow-v2.yml index 6574cc0e88..216d6e7b9d 100644 --- a/.github/workflows/release-workflow-v2.yml +++ b/.github/workflows/release-workflow-v2.yml @@ -18,13 +18,15 @@ jobs: INPUT_TOKEN: ${{ secrets.NPM_TOKEN }} steps: # 1. Setup - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 + with: + persist-credentials: false + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3 with: node-version: 22 - name: Get semver info id: semver - uses: akshens/semver-tag@v4 + uses: akshens/semver-tag@8e427cd48c699c97d021df4946f3a0e65af5047e # v4 with: version: ${{ github.ref_name }} @@ -57,7 +59,7 @@ jobs: # 2. Prepare release files - run: mkdir release && mkdir p5 && cp -r ./lib/* p5/ - name: Create release zip file - uses: TheDoctor0/zip-release@0.6.2 + uses: TheDoctor0/zip-release@09336613be18a8208dfa66bd57efafd9e2685657 # 0.6.2 with: type: zip filename: release/p5.zip @@ -68,15 +70,15 @@ jobs: # 3. Release p5.js - name: Create GitHub release - uses: softprops/action-gh-release@v0.1.15 + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 with: draft: true prerelease: ${{ steps.semver.outputs.is-prerelease == 'true' }} files: release/* generate_release_notes: true - token: ${{ secrets.ACCESS_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} - name: Publish to NPM - uses: JS-DevTools/npm-publish@v1 + uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 with: token: ${{ secrets.NPM_TOKEN }} tag: ${{ steps.semver.outputs.is-prerelease != 'true' && 'latest' || 'beta' }} @@ -84,13 +86,14 @@ jobs: # 4. Update p5.js website - name: Clone p5.js website if: ${{ steps.semver.outputs.is-prerelease != 'true' }} - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 with: repository: processing/p5.js-website ref: '2.0' path: website fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} + persist-credentials: false - name: Updated website files if: ${{ steps.semver.outputs.is-prerelease != 'true' }} run: | @@ -111,7 +114,7 @@ jobs: git commit -m "Update p5.js to ${{ github.ref_name }}" - name: Push updated website repo if: ${{ steps.semver.outputs.is-prerelease != 'true' }} - uses: ad-m/github-push-action@v0.6.0 + uses: ad-m/github-push-action@40bf560936a8022e68a3c00e7d2abefaf01305a6 # v0.6.0 with: github_token: ${{ secrets.ACCESS_TOKEN }} branch: '2.0' diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index 714f0890d0..40a944fa60 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -18,13 +18,15 @@ jobs: INPUT_TOKEN: ${{ secrets.NPM_TOKEN }} steps: # 1. Setup - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 + with: + persist-credentials: false + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3 with: node-version: 22 - name: Get semver info id: semver - uses: akshens/semver-tag@v4 + uses: akshens/semver-tag@8e427cd48c699c97d021df4946f3a0e65af5047e # v4 with: version: ${{ github.ref_name }} @@ -51,7 +53,7 @@ jobs: # 2. Prepare release files - run: mkdir release && mkdir p5 && cp -r ./lib/* p5/ - name: Create release zip file - uses: TheDoctor0/zip-release@0.6.2 + uses: TheDoctor0/zip-release@09336613be18a8208dfa66bd57efafd9e2685657 # 0.6.2 with: type: zip filename: release/p5.zip @@ -62,28 +64,29 @@ jobs: # 3. Release p5.js - name: Create GitHub release - uses: softprops/action-gh-release@v0.1.15 + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 with: draft: true prerelease: ${{ steps.semver.outputs.is-prerelease == 'true' }} files: release/* generate_release_notes: true - token: ${{ secrets.ACCESS_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} - name: Publish to NPM if: ${{ steps.semver.outputs.is-prerelease != 'true' }} - uses: JS-DevTools/npm-publish@v1 + uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1 with: token: ${{ secrets.NPM_TOKEN }} # 4. Update p5.js website - name: Clone p5.js website if: ${{ steps.semver.outputs.is-prerelease != 'true' }} - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 with: repository: processing/p5.js-website path: website fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} + persist-credentials: false - name: Updated website files if: ${{ steps.semver.outputs.is-prerelease != 'true' }} run: | @@ -104,7 +107,7 @@ jobs: git commit -m "Update p5.js to ${{ github.ref_name }}" - name: Push updated website repo if: ${{ steps.semver.outputs.is-prerelease != 'true' }} - uses: ad-m/github-push-action@v0.6.0 + uses: ad-m/github-push-action@40bf560936a8022e68a3c00e7d2abefaf01305a6 # v0.6.0 with: github_token: ${{ secrets.ACCESS_TOKEN }} branch: main @@ -114,12 +117,13 @@ jobs: # 5. Update Bower files - name: Checkout Bower repo if: ${{ steps.semver.outputs.is-prerelease != 'true' }} - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 with: repository: processing/p5.js-release path: bower fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} + persist-credentials: false - name: Copy new version files to Bower repo if: ${{ steps.semver.outputs.is-prerelease != 'true' }} run: | @@ -135,7 +139,7 @@ jobs: git commit -m "Update p5.js to ${{ github.ref_name }}" - name: Push updated Bower repo if: ${{ steps.semver.outputs.is-prerelease != 'true' }} - uses: ad-m/github-push-action@v0.6.0 + uses: ad-m/github-push-action@40bf560936a8022e68a3c00e7d2abefaf01305a6 # v0.6.0 with: github_token: ${{ secrets.ACCESS_TOKEN }} branch: master diff --git a/contributor_docs/contributing_to_the_p5js_reference.md b/contributor_docs/contributing_to_the_p5js_reference.md index eaa8750865..e4399211a6 100644 --- a/contributor_docs/contributing_to_the_p5js_reference.md +++ b/contributor_docs/contributing_to_the_p5js_reference.md @@ -977,21 +977,6 @@ Example: */ ``` -#### The @requires tag - -The `@requires` tag defines the required imported modules that the current module depends on. - -Example of `@for` and `@requires` - -```js -/** - * @module Color - * @submodule Creating & Reading - * @for p5 - * @requires core - * @requires constants - */ -``` #### The @beta tag - marking experimental API features This tag is used to mark that a feature is experimental and that its details may change or it may be removed. A warning will be presented explaining this on the reference page. @@ -1190,7 +1175,7 @@ In some editors, such as vs code, you can hover over a function or variable to s ### Previewing your work on the website, locally -At some point you will want to preview how your changes will look on the website. This involves run the website locally and having it import your p5.js code from a branch of your repo. +At some point you will want to preview how your changes will look on the website. This involves running the website locally and having it import your p5.js code from a branch of your repo. Steps: diff --git a/contributor_docs/documentation_style_guide.md b/contributor_docs/documentation_style_guide.md index d2299b27ab..d7a8b4a17a 100644 --- a/contributor_docs/documentation_style_guide.md +++ b/contributor_docs/documentation_style_guide.md @@ -26,6 +26,7 @@ Our community is large and diverse. Many people learn to code using p5.js, and a - [Code Samples](#code-samples) - [Comments](#comments) - [Accessible Canvas Labels](#accessible-canvas-labels) +- [Accessible Iframe Names](#accessible-iframe-names) - [Whitespace](#whitespace) - [Semicolons](#semicolons) - [Naming Conventions](#naming-conventions) @@ -234,7 +235,7 @@ let magicWord = 'Please'; ## Accessible Canvas Labels -- Use `describe()` to in p5.js example code, to add labels to your canvas so that it’s readable for screen readers. +- Use `describe()` in p5.js example code to add labels to your canvas so that it’s readable for screen readers. > Why? It makes examples accessible to screen readers, and models how to write good canvas labels. @@ -273,6 +274,27 @@ The above examples and suggestions are based on the [Writing Accessible Canvas D To understand the structure of p5.js’ web accessibility features for contributors, see the [Web Accessibility Contributor Doc](./web_accessibility.md#user-generated-accessible-canvas-descriptions). +**[⬆ back to top](#table-of-contents)** + +## Accessible Iframe Names + +- When embedding content with ` + + + +``` + +- Use a `title` that describes what the iframe contains, not just "iframe" or "embedded content." + +- If the iframe is purely decorative and carries no meaningful content, use `aria-hidden="true"` instead. + + **[⬆ back to top](#table-of-contents)** ## Whitespace diff --git a/contributor_docs/project_wrapups/katiejliu_gsoc_2021.md b/contributor_docs/project_wrapups/katiejliu_gsoc_2021.md index 750600bd46..7c42909474 100644 --- a/contributor_docs/project_wrapups/katiejliu_gsoc_2021.md +++ b/contributor_docs/project_wrapups/katiejliu_gsoc_2021.md @@ -3,7 +3,7 @@ #### Mentors: Rachel Lim and Claire Kearney-Volpe ### Overview - For my Google Summer of Code project, I added alt text to the visual elements of the p5.js website, specfically to all of the examples. With the help of my mentors Rachel and Claire, I was able to improve the accessibility of the p5.js website for users with visually impairment. + For my Google Summer of Code project, I added alt text to the visual elements of the p5.js website, specifically to all of the examples. With the help of my mentors Rachel and Claire, I was able to improve the accessibility of the p5.js website for users with visually impairment. ### Process To begin, I did a lot of research on best alt text practices. I read about web accessibility through sources such as articles from WebAIM and the Web Content Accessibility Guidelines from W3C. I also familiarized myself with navigating and using the built-in screen reader on my Mac. diff --git a/contributor_docs/working_with_contributor_documents.md b/contributor_docs/working_with_contributor_documents.md new file mode 100644 index 0000000000..fd7d660bd7 --- /dev/null +++ b/contributor_docs/working_with_contributor_documents.md @@ -0,0 +1,163 @@ + +# Working with contributor documents + +## Table of Contents +* [Where are they?](#where-are-they) +* [Build process overview](#build-process-overview) +* [Generating and previewing contributor documents](#generating-and-previewing-contributor-documents) +* [Adding a new contributor document](#adding-a-new-contributor-document) + +## Where are they? +Contributor documents are displayed on the p5.js website at either: + +* https://p5js.org/contribute/ (for p5.js v1) +* https://beta.p5js.org/contribute/ (for p5.js v2) + +Their source materials are kept in: +* repo: `p5.js` +* path: `contributor_docs/` + +(Note: The v1.x and v2.x branches have different documents.) + +## Build process overview + +During the _website_ build process `build:contributor-docs` the documents are cloned from the requested branch of the p5.js repo into the relevant website file-system locations. From here, the astro dev server can show previews of how they will look. (Exact instructions and paths follow.) + +## Generating and previewing contributor documents + +### Quick preview +For a quick preview, various editors have a feature to render markdown files. For example, if you're using vs code here's how to do that: + +* open the .md file you wish to preview +* open the command-palette (`F1` or `cmd-shift-p` or `ctrl-shift-p`) +* type `Markdown: open preview` + +There are various limitations to this quick-preview. For example, the p5.js website page layout and styling (colors, fonts, line-width, etc) will not be applied. + +### Preview on local p5.js-website clone + +At some point you will want to preview how your changes will look on the website. This involves run the website locally and having it import the contributor docs from a branch of your p5.js repo. + +In the following steps we'll assume your p5.js repository is in a folder called `p5.js` and your p5.js-website repo is in a folder next to it called `p5.js-website`. + +```mermaid +--- +title: Assumed local repo folder setup +--- +flowchart TD + parent --- p5.js + parent --- p5.js-website +``` + +#### Steps: + +1. Commit your changes to a local branch of your fork of the p5.js repo. The changes don't need to be pushed to github for this purpose, but they do need to be committed on a branch. +1. Clone [the p5.js-website repo](https://github.com/processing/p5.js-website/tree/2.0) locally. +1. Open a terminal in your new p5.js-website repo +1. Check out the branch "2.0" +1. Run `npm install` +1. Modify and run the following command, using the path to **your** local p5.js repo, and the name of **your** branch: + +(Note the following is a single line, not two lines!) + +```sh +P5_REPO_URL=path/to/your/p5/repo P5_BRANCH=your-branch-goes-here npm run build:contributor-docs && npm run dev +``` + +For example, if your work is in a branch called `my-amazing-branch` on a local p5.js repo called `p5.js` as a sibling folder next to the current `p5.js-website` folder, you could run the following: + +```sh +P5_REPO_URL=../p5.js P5_BRANCH=my-amazing-branch npm run build:contributor-docs && npm run dev +``` + +This will do three things: +1. import and build local website `.mdx` pages from the `.md` files in `contributor_docs/` in your branch +2. start a development preview of the website +3. display a URL in the console where you can visit the local website + +Use your browser to visit the URL mentioned in the last step, in order to test out your changes. + +#### Alternative: Building from a branch on github + +If you prefer to preview work that's already on github, you can do so. In the final command, use the repo URL instead of its local path, as follows: + +(Again, note the following is a single line, not two lines!) + +```sh +P5_REPO_URL=https://github.com/yourUsername/p5.js.git P5_BRANCH=your-branch-goes-here npm run build:contributor-docs && npm run dev +``` + +#### Troubleshooting + +If your file isn't appearing in the list the website shows at the path `contribute/`: + +* Note that it will appear with a title taken from the first level 1 markdown heading, NOT the name of the file. + +* Check that a corresponding `.mdx` file is being generated in `src/content/contributor-docs/en` in the website file-structure. If not, check if your .md file is git in the branch you've specified, in the correct location, and check the logs, during the website build:contributor-docs + +* Review the log from the above run of the npm `build:contributor-docs` process, for mentions of your file(s). + +* If you see that an .mdx file _is_ being generated, check that you can access it directly on the website by typing its URL. e.g. if your file is called myFile.md, the path in the URL would be: `contribute/myFile/` + +* Ensure you see in the log that your repo _has_ actually been cloned. There is a caching mechanism in the website build process which prevents a recently cloned repo from being cloned again. Removing the website folder `in/p5.js/` will force the build process to make a new clone. + +#### Limitations + +The website won't be _fully_ functional when partially prepared in this way. Notably: + +* Links between pages may be broken: + * You'll need to ensure local links end with a trailing slash '/', to be matched by Astro when it is in development mode. +* The search facility will not work by default + * Look into `npm run build:search` to build the necessary index files. + + +## Adding a new contributor document + +We'll consider file path, filename, file extension, title, subtitle, and eventual URL path for your new document. + +#### The file path + +It should be stored in `contributor_docs/` folder in the _p5.js_ repo. + +It should be stored as a direct child of that folder, _not_ in a subfolder. + +#### The filename and extension + +The filename won't be used as the document title but _will_ be used in URLs. The filename should be all in lowercase, and use underscore characters `_` instead of spaces or dashes. + +It should have a `.md` file extension. + +Keep the filename concise but do not use contractions. e.g. "documentation_style_guide" not "doc_style_guide". If in doubt check the names of the other documents in the folder and try to stay aligned with those. + +#### The _name_ of your document +For presentation purposes in the list of contributor docs and for search results, your document title will be taken from the first level 1 markdown heading, NOT the name of the file. + +#### The subtitle for your document +In the list of contributor docs, each page is listed not only with its title but also a subtitle or short description. + +This description is extracted from the first HTML-style comment in your file. This should be on the first line, before the level 1 heading. + +Example: + + + +In `contributor_docs/unit_testing.md` the file content starts: + +
+<!‐- Guide to writing tests for p5.js source code. ‐-> + +\# Unit Testing +
+ +As a result, this will list a page titled "Unit Testing" with a description of "Guide to writing tests for p5.js source code.". + +#### The URL for your document + +The path in the eventual URL will be +`contribute/your_filename_without_extension/` +Note that the trailing slash is necessary in development mode. + +Example: + +The source document `contributor_docs/unit_testing.md` will be served as `https://beta.p5js.org/contribute/unit_testing/` + diff --git a/docs/parameterData.json b/docs/parameterData.json index 6cf76f3c4a..0bdd3fa6da 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -1,4 +1,48 @@ { + "p5.Color": { + "toString": { + "overloads": [ + [ + "String?" + ] + ] + }, + "contrast": { + "overloads": [ + [ + "Color" + ] + ] + }, + "setRed": { + "overloads": [ + [ + "Number" + ] + ] + }, + "setGreen": { + "overloads": [ + [ + "Number" + ] + ] + }, + "setBlue": { + "overloads": [ + [ + "Number" + ] + ] + }, + "setAlpha": { + "overloads": [ + [ + "Number" + ] + ] + } + }, "p5.Image": { "pixelDensity": { "overloads": [ @@ -170,50 +214,6 @@ ] } }, - "p5.Color": { - "toString": { - "overloads": [ - [ - "String?" - ] - ] - }, - "contrast": { - "overloads": [ - [ - "Color" - ] - ] - }, - "setRed": { - "overloads": [ - [ - "Number" - ] - ] - }, - "setGreen": { - "overloads": [ - [ - "Number" - ] - ] - }, - "setBlue": { - "overloads": [ - [ - "Number" - ] - ] - }, - "setAlpha": { - "overloads": [ - [ - "Number" - ] - ] - } - }, "p5": { "remove": { "overloads": [ @@ -307,7 +307,8 @@ "overloads": [ [ "CENTER|RADIUS|CORNER|CORNERS" - ] + ], + [] ] }, "nf": { @@ -381,21 +382,6 @@ ] ] }, - "createCanvas": { - "overloads": [ - [ - "Number?", - "Number?", - "P2D|WEBGL|P2DHDR|WEBGPU?", - "HTMLCanvasElement?" - ], - [ - "Number?", - "Number?", - "HTMLCanvasElement?" - ] - ] - }, "textOutput": { "overloads": [ [ @@ -430,6 +416,27 @@ ] ] }, + "createCanvas": { + "overloads": [ + [ + "Number?", + "Number?", + "P2D|WEBGL|P2DHDR?", + "HTMLCanvasElement?" + ], + [ + "Number", + "Number", + "WEBGPU", + "HTMLCanvasElement?" + ], + [ + "Number?", + "Number?", + "HTMLCanvasElement?" + ] + ] + }, "loadShader": { "overloads": [ [ @@ -464,11 +471,6 @@ ] ] }, - "noSmooth": { - "overloads": [ - [] - ] - }, "orbitControl": { "overloads": [ [ @@ -479,69 +481,15 @@ ] ] }, - "beginClip": { - "overloads": [ - [ - "Object?" - ] - ] - }, - "smoothstep": { - "overloads": [ - [ - "Number", - "Number", - "Number" - ] - ] - }, - "getTexture": { - "overloads": [ - [ - null, - null - ] - ] - }, - "getWorldInputs": { - "overloads": [ - [ - "Function" - ] - ] - }, - "getPixelInputs": { - "overloads": [ - [ - "Function" - ] - ] - }, - "getFinalColor": { - "overloads": [ - [ - "Function" - ] - ] - }, - "getColor": { - "overloads": [ - [ - "Function" - ] - ] - }, - "getObjectInputs": { + "noSmooth": { "overloads": [ - [ - "Function" - ] + [] ] }, - "getCameraInputs": { + "beginClip": { "overloads": [ [ - "Function" + "Object?" ] ] }, @@ -604,6 +552,11 @@ ] ] }, + "endClip": { + "overloads": [ + [] + ] + }, "int": { "overloads": [ [ @@ -614,11 +567,6 @@ ] ] }, - "endClip": { - "overloads": [ - [] - ] - }, "copy": { "overloads": [ [ @@ -679,15 +627,78 @@ [] ] }, - "nfc": { + "instanceID": { + "overloads": [ + [] + ] + }, + "smoothstep": { "overloads": [ [ - "Number|String", - "Integer|String?" + "Number", + "Number", + "Number" + ] + ] + }, + "uniformStorage": { + "overloads": [ + [ + "String", + "p5.StorageBuffer|Function|Object?" ], [ - "Number[]", - "Integer|String?" + "p5.StorageBuffer|Function|Object?" + ] + ] + }, + "getTexture": { + "overloads": [ + [ + null, + null + ] + ] + }, + "getWorldInputs": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getPixelInputs": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getFinalColor": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getColor": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getObjectInputs": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getCameraInputs": { + "overloads": [ + [ + "Function" ] ] }, @@ -724,12 +735,15 @@ ] ] }, - "resizeCanvas": { + "nfc": { "overloads": [ [ - "Number", - "Number", - "Boolean?" + "Number|String", + "Integer|String?" + ], + [ + "Number[]", + "Integer|String?" ] ] }, @@ -793,6 +807,15 @@ ] ] }, + "resizeCanvas": { + "overloads": [ + [ + "Number", + "Number", + "Boolean?" + ] + ] + }, "saveCanvas": { "overloads": [ [ @@ -806,13 +829,6 @@ ] ] }, - "rectMode": { - "overloads": [ - [ - "CENTER|RADIUS|CORNER|CORNERS" - ] - ] - }, "atan": { "overloads": [ [ @@ -820,11 +836,6 @@ ] ] }, - "noCanvas": { - "overloads": [ - [] - ] - }, "resetMatrix": { "overloads": [ [] @@ -849,6 +860,14 @@ [] ] }, + "rectMode": { + "overloads": [ + [ + "CENTER|RADIUS|CORNER|CORNERS" + ], + [] + ] + }, "loadJSON": { "overloads": [ [ @@ -872,13 +891,9 @@ ] ] }, - "cursor": { + "noCanvas": { "overloads": [ - [ - "ARROW|CROSS|HAND|MOVE|TEXT|WAIT|String", - "Number?", - "Number?" - ] + [] ] }, "createElement": { @@ -889,6 +904,16 @@ ] ] }, + "cursor": { + "overloads": [ + [ + "ARROW|CROSS|HAND|MOVE|TEXT|WAIT|String", + "Number?", + "Number?" + ], + [] + ] + }, "year": { "overloads": [ [] @@ -908,6 +933,13 @@ ] ] }, + "buildGeometry": { + "overloads": [ + [ + "Function" + ] + ] + }, "byte": { "overloads": [ [ @@ -929,13 +961,6 @@ ] ] }, - "buildGeometry": { - "overloads": [ - [ - "Function" - ] - ] - }, "loadModel": { "overloads": [ [ @@ -980,16 +1005,6 @@ ] ] }, - "smooth": { - "overloads": [ - [] - ] - }, - "clearStorage": { - "overloads": [ - [] - ] - }, "nfp": { "overloads": [ [ @@ -1004,6 +1019,16 @@ ] ] }, + "smooth": { + "overloads": [ + [] + ] + }, + "clearStorage": { + "overloads": [ + [] + ] + }, "removeElements": { "overloads": [ [] @@ -1067,6 +1092,15 @@ ] ] }, + "lerp": { + "overloads": [ + [ + "Number", + "Number", + "Number" + ] + ] + }, "createGraphics": { "overloads": [ [ @@ -1082,34 +1116,32 @@ ] ] }, - "strokeCap": { + "frameRate": { "overloads": [ [ - "ROUND|SQUARE|PROJECT" - ] + "Number" + ], + [] ] }, - "lerp": { + "freeGeometry": { "overloads": [ [ - "Number", - "Number", - "Number" + "p5.Geometry" ] ] }, - "frameRate": { + "cos": { "overloads": [ [ "Number" - ], - [] + ] ] }, - "cos": { + "strokeCap": { "overloads": [ [ - "Number" + "ROUND|SQUARE|PROJECT" ] ] }, @@ -1160,13 +1192,6 @@ ] ] }, - "removeItem": { - "overloads": [ - [ - "String" - ] - ] - }, "rotate": { "overloads": [ [ @@ -1175,10 +1200,10 @@ ] ] }, - "freeGeometry": { + "removeItem": { "overloads": [ [ - "p5.Geometry" + "String" ] ] }, @@ -1247,11 +1272,6 @@ ] ] }, - "getTargetFrameRate": { - "overloads": [ - [] - ] - }, "nfs": { "overloads": [ [ @@ -1266,6 +1286,11 @@ ] ] }, + "getTargetFrameRate": { + "overloads": [ + [] + ] + }, "setShakeThreshold": { "overloads": [ [ @@ -1288,18 +1313,6 @@ ] ] }, - "strokeJoin": { - "overloads": [ - [ - "MITER|BEVEL|ROUND" - ] - ] - }, - "noCursor": { - "overloads": [ - [] - ] - }, "circle": { "overloads": [ [ @@ -1317,6 +1330,11 @@ ] ] }, + "noCursor": { + "overloads": [ + [] + ] + }, "loadTable": { "overloads": [ [ @@ -1328,6 +1346,16 @@ ] ] }, + "plane": { + "overloads": [ + [ + "Number?", + "Number?", + "Integer?", + "Integer?" + ] + ] + }, "red": { "overloads": [ [ @@ -1335,6 +1363,14 @@ ] ] }, + "strokeJoin": { + "overloads": [ + [ + "MITER|BEVEL|ROUND" + ], + [] + ] + }, "filter": { "overloads": [ [ @@ -1370,30 +1406,6 @@ ] ] }, - "plane": { - "overloads": [ - [ - "Number?", - "Number?", - "Integer?", - "Integer?" - ] - ] - }, - "createFramebuffer": { - "overloads": [ - [ - "Object?" - ] - ] - }, - "strokeWeight": { - "overloads": [ - [ - "Number" - ] - ] - }, "degrees": { "overloads": [ [ @@ -1408,20 +1420,10 @@ ] ] }, - "setup": { - "overloads": [ - [] - ] - }, - "draw": { - "overloads": [ - [] - ] - }, - "registerAddon": { + "createFramebuffer": { "overloads": [ [ - "Function" + "Object?" ] ] }, @@ -1451,28 +1453,64 @@ ] ] }, + "strokeWeight": { + "overloads": [ + [ + "Number" + ], + [] + ] + }, "radians": { "overloads": [ [ - "Number" + "Number" + ] + ] + }, + "deviceMoved": { + "overloads": [ + [] + ] + }, + "deviceTurned": { + "overloads": [ + [] + ] + }, + "deviceShaken": { + "overloads": [ + [] + ] + }, + "background": { + "overloads": [ + [ + "p5.Color" + ], + [ + "String", + "Number?" + ], + [ + "Number", + "Number?" + ], + [ + "Number", + "Number", + "Number", + "Number?" + ], + [ + "Number[]" + ], + [ + "p5.Image", + "Number?" ] ] }, - "deviceMoved": { - "overloads": [ - [] - ] - }, - "deviceTurned": { - "overloads": [ - [] - ] - }, - "deviceShaken": { - "overloads": [ - [] - ] - }, "directionalLight": { "overloads": [ [ @@ -1501,60 +1539,43 @@ ] ] }, - "background": { + "splitTokens": { "overloads": [ - [ - "p5.Color" - ], [ "String", - "Number?" - ], - [ - "Number", - "Number?" - ], - [ - "Number", - "Number", - "Number", - "Number?" - ], - [ - "Number[]" - ], - [ - "p5.Image", - "Number?" + "String?" ] ] }, - "clearDepth": { + "green": { "overloads": [ [ - "Number?" + "p5.Color|Number[]|String" ] ] }, - "splitTokens": { + "keyPressed": { "overloads": [ [ - "String", - "String?" + "KeyboardEvent?" ] ] }, - "keyPressed": { + "box": { "overloads": [ [ - "KeyboardEvent?" + "Number?", + "Number?", + "Number?", + "Integer?", + "Integer?" ] ] }, - "green": { + "clearDepth": { "overloads": [ [ - "p5.Color|Number[]|String" + "Number?" ] ] }, @@ -1624,26 +1645,32 @@ ] ] }, - "clear": { + "setup": { "overloads": [ - [ - "Number?", - "Number?", - "Number?", - "Number?" - ], [] ] }, - "box": { + "draw": { + "overloads": [ + [] + ] + }, + "registerAddon": { + "overloads": [ + [ + "Function" + ] + ] + }, + "clear": { "overloads": [ [ "Number?", "Number?", "Number?", - "Integer?", - "Integer?" - ] + "Number?" + ], + [] ] }, "char": { @@ -1767,13 +1794,6 @@ ] ] }, - "windowResized": { - "overloads": [ - [ - "Event?" - ] - ] - }, "shuffle": { "overloads": [ [ @@ -1782,12 +1802,10 @@ ] ] }, - "loadBytes": { + "windowResized": { "overloads": [ [ - "String|Request", - "Function?", - "Function?" + "Event?" ] ] }, @@ -1803,6 +1821,14 @@ ] ] }, + "angleMode": { + "overloads": [ + [ + "RADIANS|DEGREES" + ], + [] + ] + }, "unchar": { "overloads": [ [ @@ -1813,12 +1839,13 @@ ] ] }, - "angleMode": { + "sphere": { "overloads": [ [ - "RADIANS|DEGREES" - ], - [] + "Number?", + "Integer?", + "Integer?" + ] ] }, "loadPixels": { @@ -1826,6 +1853,15 @@ [] ] }, + "loadBytes": { + "overloads": [ + [ + "String|Request", + "Function?", + "Function?" + ] + ] + }, "buildFilterShader": { "overloads": [ [ @@ -1857,15 +1893,6 @@ ] ] }, - "sphere": { - "overloads": [ - [ - "Number?", - "Integer?", - "Integer?" - ] - ] - }, "keyReleased": { "overloads": [ [ @@ -1880,15 +1907,6 @@ ] ] }, - "loadBlob": { - "overloads": [ - [ - "String|Request", - "Function?", - "Function?" - ] - ] - }, "pow": { "overloads": [ [ @@ -1897,10 +1915,12 @@ ] ] }, - "fullscreen": { + "loadBlob": { "overloads": [ [ - "Boolean?" + "String|Request", + "Function?", + "Function?" ] ] }, @@ -1932,6 +1952,13 @@ ] ] }, + "fullscreen": { + "overloads": [ + [ + "Boolean?" + ] + ] + }, "point": { "overloads": [ [ @@ -2014,6 +2041,13 @@ ] ] }, + "imageLight": { + "overloads": [ + [ + "p5.Image" + ] + ] + }, "httpGet": { "overloads": [ [ @@ -2037,13 +2071,6 @@ [] ] }, - "imageLight": { - "overloads": [ - [ - "p5.Image" - ] - ] - }, "updatePixels": { "overloads": [ [ @@ -2062,11 +2089,6 @@ ] ] }, - "displayDensity": { - "overloads": [ - [] - ] - }, "panorama": { "overloads": [ [ @@ -2082,6 +2104,11 @@ ] ] }, + "displayDensity": { + "overloads": [ + [] + ] + }, "keyTyped": { "overloads": [ [ @@ -2096,37 +2123,27 @@ ] ] }, - "scale": { + "cylinder": { "overloads": [ [ - "Number|p5.Vector|Number[]", "Number?", - "Number?" - ], - [ - "p5.Vector|Number[]" + "Number?", + "Integer?", + "Integer?", + "Boolean?", + "Boolean?" ] ] }, - "httpPost": { + "scale": { "overloads": [ [ - "String|Request", - "Object|Boolean?", - "String?", - "Function?", - "Function?" - ], - [ - "String|Request", - "Object|Boolean", - "Function?", - "Function?" + "Number|p5.Vector|Number[]", + "Number?", + "Number?" ], [ - "String|Request", - "Function?", - "Function?" + "p5.Vector|Number[]" ] ] }, @@ -2179,15 +2196,25 @@ ] ] }, - "cylinder": { + "httpPost": { "overloads": [ [ - "Number?", - "Number?", - "Integer?", - "Integer?", - "Boolean?", - "Boolean?" + "String|Request", + "Object|Boolean?", + "String?", + "Function?", + "Function?" + ], + [ + "String|Request", + "Object|Boolean", + "Function?", + "Function?" + ], + [ + "String|Request", + "Function?", + "Function?" ] ] }, @@ -2208,11 +2235,6 @@ ] ] }, - "getURLPath": { - "overloads": [ - [] - ] - }, "splineTangent": { "overloads": [ [ @@ -2224,6 +2246,11 @@ ] ] }, + "getURLPath": { + "overloads": [ + [] + ] + }, "normal": { "overloads": [ [ @@ -2261,9 +2288,11 @@ ] ] }, - "getURLParams": { + "shearX": { "overloads": [ - [] + [ + "Number" + ] ] }, "colorMode": { @@ -2282,11 +2311,9 @@ [] ] }, - "shearX": { + "getURLParams": { "overloads": [ - [ - "Number" - ] + [] ] }, "lightFalloff": { @@ -2312,13 +2339,6 @@ ] ] }, - "keyIsDown": { - "overloads": [ - [ - "Number|String" - ] - ] - }, "shader": { "overloads": [ [ @@ -2326,11 +2346,10 @@ ] ] }, - "model": { + "keyIsDown": { "overloads": [ [ - "p5.Geometry", - "Number?" + "Number|String" ] ] }, @@ -2341,23 +2360,6 @@ ] ] }, - "httpDo": { - "overloads": [ - [ - "String|Request", - "String?", - "String?", - "Object?", - "Function?", - "Function?" - ], - [ - "String|Request", - "Function?", - "Function?" - ] - ] - }, "rect": { "overloads": [ [ @@ -2387,6 +2389,23 @@ ] ] }, + "httpDo": { + "overloads": [ + [ + "String|Request", + "String?", + "String?", + "Object?", + "Function?", + "Function?" + ], + [ + "String|Request", + "Function?", + "Function?" + ] + ] + }, "saturation": { "overloads": [ [ @@ -2394,6 +2413,14 @@ ] ] }, + "model": { + "overloads": [ + [ + "p5.Geometry", + "Number?" + ] + ] + }, "createSelect": { "overloads": [ [ @@ -2404,6 +2431,17 @@ ] ] }, + "cone": { + "overloads": [ + [ + "Number?", + "Number?", + "Integer?", + "Integer?", + "Boolean?" + ] + ] + }, "worldToScreen": { "overloads": [ [ @@ -2413,14 +2451,24 @@ ] ] }, - "cone": { + "vertexProperty": { + "overloads": [ + [ + "String", + "Number|Number[]" + ] + ] + }, + "square": { "overloads": [ [ + "Number", + "Number", + "Number", "Number?", "Number?", - "Integer?", - "Integer?", - "Boolean?" + "Number?", + "Number?" ] ] }, @@ -2446,27 +2494,6 @@ ] ] }, - "vertexProperty": { - "overloads": [ - [ - "String", - "Number|Number[]" - ] - ] - }, - "square": { - "overloads": [ - [ - "Number", - "Number", - "Number", - "Number?", - "Number?", - "Number?", - "Number?" - ] - ] - }, "screenToWorld": { "overloads": [ [ @@ -2490,6 +2517,13 @@ ] ] }, + "brightness": { + "overloads": [ + [ + "p5.Color|Number[]|String" + ] + ] + }, "tint": { "overloads": [ [ @@ -2510,21 +2544,19 @@ ], [ "p5.Color" - ] + ], + [] ] }, - "brightness": { + "translate": { "overloads": [ [ - "p5.Color|Number[]|String" - ] - ] - }, - "createWriter": { - "overloads": [ + "Number", + "Number", + "Number?" + ], [ - "String", - "String?" + "p5.Vector" ] ] }, @@ -2612,23 +2644,14 @@ ] ] }, - "translate": { + "createWriter": { "overloads": [ [ - "Number", - "Number", - "Number?" - ], - [ - "p5.Vector" + "String", + "String?" ] ] }, - "noTint": { - "overloads": [ - [] - ] - }, "fill": { "overloads": [ [ @@ -2649,7 +2672,13 @@ ], [ "p5.Color" - ] + ], + [] + ] + }, + "noTint": { + "overloads": [ + [] ] }, "triangle": { @@ -2838,13 +2867,6 @@ [] ] }, - "write": { - "overloads": [ - [ - "String|Number|Array" - ] - ] - }, "lightness": { "overloads": [ [ @@ -2852,10 +2874,10 @@ ] ] }, - "imageMode": { + "imageShader": { "overloads": [ [ - "CORNER|CORNERS|CENTER" + "p5.Shader" ] ] }, @@ -2866,10 +2888,10 @@ ] ] }, - "imageShader": { + "write": { "overloads": [ [ - "p5.Shader" + "String|Number|Array" ] ] }, @@ -2878,6 +2900,14 @@ [] ] }, + "imageMode": { + "overloads": [ + [ + "CORNER|CORNERS|CENTER" + ], + [] + ] + }, "createRadio": { "overloads": [ [ @@ -2930,14 +2960,6 @@ ] ] }, - "paletteLerp": { - "overloads": [ - [ - "[p5.Color|String|Number|Number[], Number][]", - "Number" - ] - ] - }, "torus": { "overloads": [ [ @@ -2948,6 +2970,14 @@ ] ] }, + "paletteLerp": { + "overloads": [ + [ + "[p5.Color|String|Number|Number[], Number][]", + "Number" + ] + ] + }, "close": { "overloads": [ [] @@ -3001,7 +3031,8 @@ ], [ "p5.Color" - ] + ], + [] ] }, "createColorPicker": { @@ -3011,15 +3042,6 @@ ] ] }, - "save": { - "overloads": [ - [ - "Object|String?", - "String?", - "Boolean|String?" - ] - ] - }, "loadMaterialShader": { "overloads": [ [ @@ -3029,12 +3051,13 @@ ] ] }, - "bezierOrder": { + "save": { "overloads": [ [ - "Number" - ], - [] + "Object|String?", + "String?", + "Boolean|String?" + ] ] }, "doubleClicked": { @@ -3049,22 +3072,17 @@ [] ] }, - "erase": { - "overloads": [ - [ - "Number?", - "Number?" - ] - ] - }, "baseFilterShader": { "overloads": [ [] ] }, - "noErase": { + "erase": { "overloads": [ - [] + [ + "Number?", + "Number?" + ] ] }, "createInput": { @@ -3078,13 +3096,9 @@ ] ] }, - "saveJSON": { + "noErase": { "overloads": [ - [ - "Array|Object", - "String", - "Boolean?" - ] + [] ] }, "buildNormalShader": { @@ -3106,6 +3120,15 @@ ] ] }, + "saveJSON": { + "overloads": [ + [ + "Array|Object", + "String", + "Boolean?" + ] + ] + }, "pop": { "overloads": [ [] @@ -3119,11 +3142,6 @@ ] ] }, - "requestPointerLock": { - "overloads": [ - [] - ] - }, "loadNormalShader": { "overloads": [ [ @@ -3133,11 +3151,24 @@ ] ] }, + "requestPointerLock": { + "overloads": [ + [] + ] + }, "baseNormalShader": { "overloads": [ [] ] }, + "bezierOrder": { + "overloads": [ + [ + "Number" + ], + [] + ] + }, "exitPointerLock": { "overloads": [ [] @@ -3165,32 +3196,6 @@ ] ] }, - "splineVertex": { - "overloads": [ - [ - "Number", - "Number" - ], - [ - "Number", - "Number", - "Number?" - ], - [ - "Number", - "Number", - "Number?", - "Number?" - ], - [ - "Number", - "Number", - "Number", - "Number?", - "Number?" - ] - ] - }, "saveTable": { "overloads": [ [ @@ -3229,7 +3234,8 @@ "overloads": [ [ "BLEND|DARKEST|LIGHTEST|DIFFERENCE|MULTIPLY|EXCLUSION|SCREEN|REPLACE|OVERLAY|HARD_LIGHT|SOFT_LIGHT|DODGE|BURN|ADD|REMOVE|SUBTRACT" - ] + ], + [] ] }, "camera": { @@ -3247,6 +3253,18 @@ ] ] }, + "createStorage": { + "overloads": [ + [ + "Number|Array|Float32Array|Object[]" + ] + ] + }, + "baseComputeShader": { + "overloads": [ + [] + ] + }, "buildStrokeShader": { "overloads": [ [ @@ -3259,22 +3277,27 @@ ] ] }, - "splineProperty": { + "splineVertex": { "overloads": [ [ - "String", - null + "Number", + "Number" ], [ - "String" - ] - ] - }, - "perspective": { - "overloads": [ + "Number", + "Number", + "Number?" + ], [ + "Number", + "Number", "Number?", - "Number?", + "Number?" + ], + [ + "Number", + "Number", + "Number", "Number?", "Number?" ] @@ -3289,28 +3312,38 @@ ] ] }, + "perspective": { + "overloads": [ + [ + "Number?", + "Number?", + "Number?", + "Number?" + ] + ] + }, "baseStrokeShader": { "overloads": [ [] ] }, - "splineProperties": { + "buildComputeShader": { "overloads": [ [ - "Object" + "Function" ] ] }, - "linePerspective": { + "resetShader": { "overloads": [ - [ - "Boolean" - ], [] ] }, - "resetShader": { + "linePerspective": { "overloads": [ + [ + "Boolean" + ], [] ] }, @@ -3326,6 +3359,17 @@ ] ] }, + "splineProperty": { + "overloads": [ + [ + "String", + null + ], + [ + "String" + ] + ] + }, "texture": { "overloads": [ [ @@ -3333,24 +3377,20 @@ ] ] }, - "vertex": { + "compute": { "overloads": [ [ - "Number", - "Number" - ], - [ - "Number", + "p5.Shader", "Number", "Number?", "Number?" - ], + ] + ] + }, + "curveDetail": { + "overloads": [ [ - "Number", - "Number", - "Number", - "Number?", - "Number?" + "Number" ] ] }, @@ -3366,10 +3406,10 @@ ] ] }, - "curveDetail": { + "splineProperties": { "overloads": [ [ - "Number" + "Object" ] ] }, @@ -3378,16 +3418,12 @@ [] ] }, - "beginContour": { - "overloads": [ - [] - ] - }, "textureMode": { "overloads": [ [ "IMAGE|NORMAL" - ] + ], + [] ] }, "setCamera": { @@ -3397,18 +3433,45 @@ ] ] }, - "endContour": { + "vertex": { "overloads": [ [ - "OPEN|CLOSE?" + "Number", + "Number" + ], + [ + "Number", + "Number", + "Number?", + "Number?" + ], + [ + "Number", + "Number", + "Number", + "Number?", + "Number?" ] ] }, + "beginContour": { + "overloads": [ + [] + ] + }, "textureWrap": { "overloads": [ [ "CLAMP|REPEAT|MIRROR", "CLAMP|REPEAT|MIRROR?" + ], + [] + ] + }, + "endContour": { + "overloads": [ + [ + "OPEN|CLOSE?" ] ] }, @@ -4193,44 +4256,6 @@ ] } }, - "p5.Shader": { - "version": { - "overloads": [ - [] - ] - }, - "inspectHooks": { - "overloads": [ - [] - ] - }, - "modify": { - "overloads": [ - [ - "Function", - "Object?" - ], - [ - "Object?" - ] - ] - }, - "copyToContext": { - "overloads": [ - [ - "p5|p5.Graphics" - ] - ] - }, - "setUniform": { - "overloads": [ - [ - "String", - "Boolean|Number|Number[]|p5.Image|p5.Graphics|p5.MediaElement|p5.Texture" - ] - ] - } - }, "p5.Table": { "addRow": { "overloads": [ @@ -4405,6 +4430,44 @@ ] } }, + "p5.Shader": { + "version": { + "overloads": [ + [] + ] + }, + "inspectHooks": { + "overloads": [ + [] + ] + }, + "modify": { + "overloads": [ + [ + "Function", + "Object?" + ], + [ + "Object?" + ] + ] + }, + "copyToContext": { + "overloads": [ + [ + "p5|p5.Graphics" + ] + ] + }, + "setUniform": { + "overloads": [ + [ + "String", + "Boolean|p5.Vector|p5.Color|Number|Number[]|p5.Image|p5.Graphics|p5.MediaElement|p5.Texture|p5.StorageBuffer" + ] + ] + } + }, "p5.MediaElement": { "play": { "overloads": [ @@ -4517,6 +4580,15 @@ ] } }, + "p5.StorageBuffer": { + "update": { + "overloads": [ + [ + "Number[]|Float32Array|Object[]" + ] + ] + } + }, "p5.Geometry": { "calculateBoundingBox": { "overloads": [ diff --git a/package-lock.json b/package-lock.json index acaf094c02..5abf442b43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "p5", - "version": "2.2.3", + "version": "2.3.0-rc.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "p5", - "version": "2.2.3", + "version": "2.3.0-rc.6", "license": "LGPL-2.1", "dependencies": { "@davepagurek/bezier-path": "^0.0.7", @@ -46,6 +46,7 @@ "lint-staged": "^15.1.0", "msw": "^2.6.3", "pixelmatch": "^7.1.0", + "pkg-pr-new": "^0.0.65", "rollup": "^4.9.6", "rollup-plugin-string": "^3.0.0", "rollup-plugin-visualizer": "^5.12.0", @@ -56,6 +57,45 @@ "webdriverio": "^9.0.7" } }, + "node_modules/@actions/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz", + "integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz", + "integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -2273,6 +2313,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ez-spawn": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@jsdevtools/ez-spawn/-/ez-spawn-3.0.4.tgz", + "integrity": "sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-me-maybe": "^1.0.1", + "cross-spawn": "^7.0.3", + "string-argv": "^0.3.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.37.6", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", @@ -2329,6 +2385,278 @@ "node": ">= 8" } }, + "node_modules/@octokit/action": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@octokit/action/-/action-6.1.0.tgz", + "integrity": "sha512-lo+nHx8kAV86bxvOVOI3vFjX3gXPd/L7guAUbvs3pUvnR2KC+R7yjBkA1uACt4gYhs4LcWP3AXSGQzsbeN2XXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-action": "^4.0.0", + "@octokit/core": "^5.0.0", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/plugin-rest-endpoint-methods": "^10.0.0", + "@octokit/types": "^12.0.0", + "undici": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-action": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-action/-/auth-action-4.1.0.tgz", + "integrity": "sha512-m+3t7K46IYyMk7Bl6/lF4Rv09GqDZjYmNg8IWycJ2Fa3YE3DE7vQcV6G2hUPmR9NDqenefNJwVtlisMjzymPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/types": "^13.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-action/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/auth-action/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", + "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/core/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/graphql/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.2.tgz", + "integrity": "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.4.1.tgz", + "integrity": "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/request-error/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/request/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, "node_modules/@open-draft/deferred-promise": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", @@ -4333,6 +4661,13 @@ "node": ">=10.0.0" } }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4452,6 +4787,13 @@ "dev": true, "license": "MIT" }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4949,6 +5291,13 @@ "node": ">=12" } }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5165,6 +5514,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decode-uri-component": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", + "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5217,6 +5576,13 @@ "node": ">= 14" } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true, + "license": "ISC" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -6353,6 +6719,19 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", + "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -7529,6 +7908,19 @@ "dev": true, "license": "MIT" }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9915,6 +10307,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -10697,6 +11102,38 @@ "node": ">=10" } }, + "node_modules/pkg-pr-new": { + "version": "0.0.65", + "resolved": "https://registry.npmjs.org/pkg-pr-new/-/pkg-pr-new-0.0.65.tgz", + "integrity": "sha512-yZGN17qf6SX/go2rlLL3OWtwO0ppKZBxAfG5M3a9N44WMDrhqkuyfQOpKdvRE9plFsCzF7L/Xtp/1hQQHqhhCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/core": "^3.0.0", + "@jsdevtools/ez-spawn": "^3.0.4", + "@octokit/action": "^6.1.0", + "ignore": "^5.3.1", + "isbinaryfile": "^5.0.2", + "pkg-types": "^1.1.1", + "query-registry": "^3.0.1", + "tinyglobby": "^0.2.9" + }, + "bin": { + "pkg-pr-new": "bin/cli.js" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", @@ -10871,6 +11308,34 @@ "node": ">=6" } }, + "node_modules/query-registry": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/query-registry/-/query-registry-3.0.1.tgz", + "integrity": "sha512-M9RxRITi2mHMVPU5zysNjctUT8bAPx6ltEXo/ir9+qmiM47Y7f0Ir3+OxUO5OjYAWdicBQRew7RtHtqUXydqlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "query-string": "^9.0.0", + "quick-lru": "^7.0.0", + "url-join": "^5.0.0", + "validate-npm-package-name": "^5.0.1", + "zod": "^3.23.8", + "zod-package-json": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/query-registry/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/query-selector-shadow-dom": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", @@ -10878,6 +11343,24 @@ "dev": true, "license": "MIT" }, + "node_modules/query-string": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.3.1.tgz", + "integrity": "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.4.1", + "filter-obj": "^5.1.0", + "split-on-first": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -10906,6 +11389,19 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -11952,6 +12448,19 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/split-on-first": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz", + "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -12391,6 +12900,16 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12404,6 +12923,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", @@ -12431,6 +12960,13 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/unbzip2-stream": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", @@ -12478,9 +13014,9 @@ } }, "node_modules/undici": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz", - "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "dev": true, "license": "MIT", "engines": { @@ -12612,6 +13148,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true, + "license": "ISC" + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -12663,6 +13206,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -12728,6 +13281,16 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/vfile": { "version": "5.3.7", "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", @@ -13565,6 +14128,29 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-package-json": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zod-package-json/-/zod-package-json-1.2.0.tgz", + "integrity": "sha512-tamtgPM3MkP+obfO2dLr/G+nYoYkpJKmuHdYEy6IXRKfLybruoJ5NUj0lM0LxwOpC9PpoGLbll1ecoeyj43Wsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^3.25.64" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/zod-package-json/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index d1c1f7df2c..df19866fe3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test/**/*.js": "eslint", "utils/**/*.{js,mjs}": "eslint" }, - "version": "2.2.3", + "version": "2.3.0-rc.6", "dependencies": { "@davepagurek/bezier-path": "^0.0.7", "@japont/unicode-range": "^1.0.0", @@ -59,6 +59,7 @@ "lint-staged": "^15.1.0", "msw": "^2.6.3", "pixelmatch": "^7.1.0", + "pkg-pr-new": "^0.0.65", "rollup": "^4.9.6", "rollup-plugin-string": "^3.0.0", "rollup-plugin-visualizer": "^5.12.0", @@ -79,6 +80,10 @@ "types": "./types/global.d.ts", "default": "./dist/app.js" }, + "./node": { + "types": "./types/p5.d.ts", + "default": "./dist/app.node.js" + }, "./core": { "default": "./dist/core/main.js" }, diff --git a/preview/index.html b/preview/index.html index ed76b913f5..055f642f2e 100644 --- a/preview/index.html +++ b/preview/index.html @@ -32,6 +32,14 @@ let env; let instance; + // Compute shader variables + let computeShader; + let particleBuffer; + let bouncingCirclesShader; + let circleGeometry; + const NUM_CIRCLES = 100; + const RADIUS = 2; + p.setup = async function () { await p.createCanvas(400, 400, p.WEBGPU); env = await p.loadImage('img/spheremap.jpg'); @@ -41,6 +49,7 @@ fbo = p.createFramebuffer(); instance = p.buildGeometry(() => p.sphere(5)); + circleGeometry = p.buildGeometry(() => p.sphere(RADIUS)); redFilter = p.baseFilterShader().modify(() => { p.getColor((inputs, canvasContent) => { @@ -64,22 +73,10 @@ } tex.updatePixels(); fbo.draw(() => { - //p.clear(); - //p.background('orange'); p.imageMode(p.CENTER); p.image(tex, 0, 0, p.width, p.height); }); - /*sh = p.baseMaterialShader().modify({ - uniforms: { - 'f32 time': () => p.millis(), - }, - 'Vertex getWorldInputs': `(inputs: Vertex) { - var result = inputs; - result.position.y += 40.0 * sin(uniforms.time * 0.005); - return result; - }`, - })*/ sh = p.baseMaterialShader().modify(() => { const time = p.uniformFloat(() => p.millis()); p.getWorldInputs((inputs) => { @@ -93,19 +90,67 @@ return inputs; }); }, { p }) - /*ssh = p.baseStrokeShader().modify({ - uniforms: { - 'f32 time': () => p.millis(), - }, - 'StrokeVertex getWorldInputs': `(inputs: StrokeVertex) { - var result = inputs; - result.position.y += 40.0 * sin(uniforms.time * 0.005); - return result; - }`, - })*/ + + // Initialize storage buffers with random positions and velocities + const initialParticles = []; + for (let i = 0; i < NUM_CIRCLES; i++) { + initialParticles.push({ + position: [p.random(-150, 150), p.random(-150, 150)], + velocity: [ + 0.1 * p.random(1, 3) * (p.random() > 0.5 ? 1 : -1), + 0.1 * p.random(1, 3) * (p.random() > 0.5 ? 1 : -1) + ] + }) + } + + particleBuffer = p.createStorage(initialParticles); + + // Create compute shader for physics simulation + computeShader = p.buildComputeShader(() => { + const particles = p.uniformStorage('particles', particleBuffer); + const bounds = p.uniformVec2(() => [p.width / 2 - RADIUS, p.height / 2 - RADIUS]); + const deltaTime = p.uniformFloat(() => p.deltaTime * 0.1); + + const idx = p.index.x; + + // Read current position and velocity + let position = particles[idx].position; + let velocity = particles[idx].velocity; + + // Update position + position += velocity * deltaTime; + + // Bounce off boundaries + if (position.x > bounds.x || position.x < -bounds.x) { + velocity.x = -velocity.x; + position.x = p.clamp(position.x, -bounds.x, bounds.x); + } + if (position.y > bounds.y || position.y < -bounds.y) { + velocity.y = -velocity.y; + position.y = p.clamp(position.y, -bounds.y, bounds.y); + } + + particles[idx].position = position; + particles[idx].velocity = velocity; + }, { p, RADIUS, particleBuffer }); + + // Shader for rendering bouncing circles from storage buffer + bouncingCirclesShader = p.baseMaterialShader().modify(() => { + const particles = p.uniformStorage('particles', particleBuffer); + + p.getWorldInputs((inputs) => { + const instanceIdx = p.instanceID(); + inputs.position.xy += particles[instanceIdx].position; + return inputs; + }); + }, { p, particleBuffer }); }; p.draw = function () { + // Run compute shader to update physics + debugger + p.compute(computeShader, NUM_CIRCLES); + p.clear(); p.rotateY(p.millis() * 0.001); p.push(); @@ -168,6 +213,14 @@ p.model(instance, 10); p.pop(); + // Draw compute shader-driven bouncing circles + p.push(); + p.shader(bouncingCirclesShader); + p.noStroke(); + p.fill('#4ECDC4'); + p.model(circleGeometry, NUM_CIRCLES); + p.pop(); + // Test beginShape/endShape with immediate mode shapes p.push(); p.translate(0, 100, 0); diff --git a/rollup.config.mjs b/rollup.config.mjs index ce03d12124..208499561c 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -193,7 +193,7 @@ export default [ format: 'es', dir: 'dist' }, - external: /node_modules/, + external: /node_modules\/(?!gifenc)/, plugins }, ...generateModuleBuild() diff --git a/src/accessibility/color_namer.js b/src/accessibility/color_namer.js index bfe1c96a58..9a9225c2eb 100644 --- a/src/accessibility/color_namer.js +++ b/src/accessibility/color_namer.js @@ -2,7 +2,6 @@ * @module Environment * @submodule Environment * @for p5 - * @requires core */ import color_conversion from '../color/color_conversion'; diff --git a/src/accessibility/describe.js b/src/accessibility/describe.js index 4ede8cef65..8310fda3bf 100644 --- a/src/accessibility/describe.js +++ b/src/accessibility/describe.js @@ -2,7 +2,6 @@ * @module Environment * @submodule Environment * @for p5 - * @requires core */ function describe(p5, fn){ diff --git a/src/accessibility/gridOutput.js b/src/accessibility/gridOutput.js index 21b900b24e..fc3e67ed78 100644 --- a/src/accessibility/gridOutput.js +++ b/src/accessibility/gridOutput.js @@ -2,7 +2,6 @@ * @module Environment * @submodule Environment * @for p5 - * @requires core */ function gridOutput(p5, fn){ diff --git a/src/accessibility/outputs.js b/src/accessibility/outputs.js index 1443d9004c..5fd5183016 100644 --- a/src/accessibility/outputs.js +++ b/src/accessibility/outputs.js @@ -2,7 +2,6 @@ * @module Environment * @submodule Environment * @for p5 - * @requires core */ function outputs(p5, fn){ diff --git a/src/accessibility/textOutput.js b/src/accessibility/textOutput.js index 971718fc26..46baa56071 100644 --- a/src/accessibility/textOutput.js +++ b/src/accessibility/textOutput.js @@ -2,7 +2,6 @@ * @module Environment * @submodule Environment * @for p5 - * @requires core */ function textOutput(p5, fn){ diff --git a/src/app.node.js b/src/app.node.js new file mode 100644 index 0000000000..ef270459b7 --- /dev/null +++ b/src/app.node.js @@ -0,0 +1,60 @@ +// core +import p5 from './core/main'; + +// shape +import shape from './shape'; +shape(p5); + +//accessibility +import accessibility from './accessibility'; +accessibility(p5); + +// color +import color from './color'; +color(p5); + +// core +// currently, it only contains the test for parameter validation +// import friendlyErrors from './core/friendly_errors'; +// friendlyErrors(p5); + +// data +import data from './data'; +data(p5); + +// DOM +import dom from './dom'; +dom(p5); + +// image +import image from './image'; +image(p5); + +// io +import io from './io'; +io(p5); + +// math +import math from './math'; +math(p5); + +// utilities +import utilities from './utilities'; +utilities(p5); + +// webgl +import webgl from './webgl'; +webgl(p5); + +// typography +import type from './type'; +type(p5); + +// Shaders + filters +import shader from './webgl/p5.Shader'; +p5.registerAddon(shader); +import strands from './strands/p5.strands'; +p5.registerAddon(strands); + +export default p5; + diff --git a/src/color/color_conversion.js b/src/color/color_conversion.js index 850d24918b..11823f077e 100644 --- a/src/color/color_conversion.js +++ b/src/color/color_conversion.js @@ -2,7 +2,6 @@ * @module Color * @submodule Color Conversion * @for p5 - * @requires core */ /** diff --git a/src/color/creating_reading.js b/src/color/creating_reading.js index a6aa934dc1..7690104406 100644 --- a/src/color/creating_reading.js +++ b/src/color/creating_reading.js @@ -2,8 +2,6 @@ * @module Color * @submodule Creating & Reading * @for p5 - * @requires core - * @requires constants */ import { Color } from './p5.Color'; diff --git a/src/color/p5.Color.js b/src/color/p5.Color.js index cd7561b939..29b5996fb3 100644 --- a/src/color/p5.Color.js +++ b/src/color/p5.Color.js @@ -2,8 +2,6 @@ * @module Color * @submodule Creating & Reading * @for p5 - * @requires core - * @requires color_conversion */ import { RGB, RGBHDR, HSL, HSB, HWB, LAB, LCH, OKLAB, OKLCH } from './creating_reading'; @@ -61,6 +59,13 @@ class Color { static #colorjsMaxes = {}; static #grayscaleMap = {}; + // This property is here where duck typing (checking if obj.isColor) needs + // to be used over more standard type checking (obj instanceof Color). This + // needs to happen where we are building multiple files, such as in p5.webgpu.js, + // where if we `import { Color }` directly, it will be a separate copy of the + // Color class from the one imported in the main p5.js bundle. + isColor = true; + // Used to add additional color modes to p5.js // Uses underlying library's definition static addColorMode(mode, definition){ @@ -364,7 +369,7 @@ class Color { if (format === undefined && this._defaultStringValue !== undefined) { return this._defaultStringValue; } - + let outputFormat = format; if (format === '#rrggbb') { outputFormat = 'hex'; @@ -377,10 +382,10 @@ class Color { colorString = serialize(this._color, { format: outputFormat }); - + if (format === '#rrggbb') { colorString = String(colorString); - if (colorString.length === 4) { + if (colorString.length === 4) { const r = colorString[1]; const g = colorString[2]; const b = colorString[3]; @@ -724,7 +729,7 @@ class Color { if(!Array.isArray(v)){ return [0, v]; }else{ - return v + return v; } }); diff --git a/src/color/setting.js b/src/color/setting.js index 6a066b986e..04ddb8372f 100644 --- a/src/color/setting.js +++ b/src/color/setting.js @@ -2,8 +2,6 @@ * @module Color * @submodule Setting * @for p5 - * @requires core - * @requires constants */ import * as constants from '../core/constants'; @@ -509,7 +507,7 @@ function setting(p5, fn){ * * describe('A canvas with a transparent green background.'); * } - * < + * * @example * function setup() { * createCanvas(100, 100); @@ -580,8 +578,7 @@ function setting(p5, fn){ * @chainable */ fn.background = function(...args) { - this._renderer.background(...args); - return this; + return this._renderer.background(...args); }; /** @@ -1096,6 +1093,8 @@ function setting(p5, fn){ * or `HSLA` colors, depending on the current colorMode(). The last parameter * sets the alpha (transparency) value. * + * Calling `fill()` without an argument returns the current fill as a p5.Color object. + * * @method fill * @param {Number} v1 red value if color mode is RGB or hue value if color mode is HSB. * @param {Number} v2 green value if color mode is RGB or saturation value if color mode is HSB. @@ -1284,9 +1283,12 @@ function setting(p5, fn){ * @param {p5.Color} color the fill color. * @chainable */ + /** + * @method fill + * @return {p5.Color} the current fill color. + */ fn.fill = function(...args) { - this._renderer.fill(...args); - return this; + return this._renderer.fill(...args); }; /** @@ -1415,6 +1417,8 @@ function setting(p5, fn){ * or HSLA colors, depending on the current `colorMode()`. The last parameter * sets the alpha (transparency) value. * + * Calling `stroke()` without an argument returns the current stroke as a p5.Color object. + * * @method stroke * @param {Number} v1 red value if color mode is RGB or hue value if color mode is HSB. * @param {Number} v2 green value if color mode is RGB or saturation value if color mode is HSB. @@ -1601,9 +1605,12 @@ function setting(p5, fn){ * @param {p5.Color} color the stroke color. * @chainable */ + /** + * @method stroke + * @return {p5.Color} the current stroke color. + */ fn.stroke = function(...args) { - this._renderer.stroke(...args); - return this; + return this._renderer.stroke(...args); }; /** @@ -1768,6 +1775,8 @@ function setting(p5, fn){ * EXCLUSION, SCREEN, REPLACE, OVERLAY, HARD_LIGHT, * SOFT_LIGHT, DODGE, BURN, ADD, REMOVE or SUBTRACT * + * Calling `blendMode()` without an argument returns the current blendMode. + * * @example * function setup() { * createCanvas(100, 100); @@ -2136,6 +2145,10 @@ function setting(p5, fn){ * describe('A yellow line and a turquoise line form an X on a gray background. The area where they overlap is green.'); * } */ + /** + * @method blendMode + * @return {(BLEND|DARKEST|LIGHTEST|DIFFERENCE|MULTIPLY|EXCLUSION|SCREEN|REPLACE|OVERLAY|HARD_LIGHT|SOFT_LIGHT|DODGE|BURN|ADD|REMOVE|SUBTRACT)} the current blend mode. + */ fn.blendMode = function (mode) { // p5._validateParameters('blendMode', arguments); if (mode === constants.NORMAL) { @@ -2145,7 +2158,7 @@ function setting(p5, fn){ ); mode = constants.BLEND; } - this._renderer.blendMode(mode); + return this._renderer.blendMode(mode); }; } diff --git a/src/core/States.js b/src/core/States.js index a70a6300c2..bc5a8a9a41 100644 --- a/src/core/States.js +++ b/src/core/States.js @@ -14,7 +14,7 @@ export class States { this[key] = value; } - getDiff() { + takeDiff() { const diff = this.#modified; this.#modified = {}; return diff; diff --git a/src/core/environment.js b/src/core/environment.js index 350d1d1e54..dcac93ad85 100644 --- a/src/core/environment.js +++ b/src/core/environment.js @@ -2,8 +2,6 @@ * @module Environment * @submodule Environment * @for p5 - * @requires core - * @requires constants */ import * as C from './constants'; @@ -11,12 +9,13 @@ import * as C from './constants'; function environment(p5, fn, lifecycles){ const standardCursors = [C.ARROW, C.CROSS, C.HAND, C.MOVE, C.TEXT, C.WAIT]; + const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; fn._frameRate = 0; - fn._lastFrameTime = window.performance.now(); + fn._lastFrameTime = globalThis.performance.now(); fn._targetFrameRate = 60; - const _windowPrint = window.print; + const windowPrint = isBrowser ? window.print : null; let windowPrintDisabled = false; lifecycles.presetup = function(){ @@ -24,11 +23,13 @@ function environment(p5, fn, lifecycles){ 'resize' ]; - for(const event of events){ - window.addEventListener(event, this[`_on${event}`].bind(this), { - passive: false, - signal: this._removeSignal - }); + if(isBrowser){ + for(const event of events){ + window.addEventListener(event, this[`_on${event}`].bind(this), { + passive: false, + signal: this._removeSignal + }); + } } }; @@ -59,9 +60,9 @@ function environment(p5, fn, lifecycles){ * } */ fn.print = function(...args) { - if (!args.length) { + if (!args.length && windowPrint !== null) { if (!windowPrintDisabled) { - _windowPrint(); + windowPrint(); if ( window.confirm( 'You just tried to print the webpage. Do you want to prevent this from running again?' @@ -198,7 +199,7 @@ function environment(p5, fn, lifecycles){ * } * } */ - fn.focused = document.hasFocus(); + fn.focused = isBrowser ? document.hasFocus() : true; /** * Changes the cursor's appearance. @@ -215,6 +216,8 @@ function environment(p5, fn, lifecycles){ * cursor, `x` and `y` set the location pointed to within the image. They are * both 0 by default, so the cursor points to the image's top-left corner. `x` * and `y` must be less than the image's width and height, respectively. + * + * Calling `cursor()` without an argument returns the current cursor type as a string. * * @method cursor * @param {(ARROW|CROSS|HAND|MOVE|TEXT|WAIT|String)} type Built-in: either ARROW, CROSS, HAND, MOVE, TEXT, or WAIT. @@ -281,9 +284,17 @@ function environment(p5, fn, lifecycles){ * } * } */ + /** + * @method cursor + * @return {(ARROW|CROSS|HAND|MOVE|TEXT|WAIT|String)} the current cursor type + */ fn.cursor = function(type, x, y) { let cursor = 'auto'; const canvas = this._curElement.elt; + if (typeof type === 'undefined') { + let curstr = canvas.style.cursor; + return curstr.length ? curstr : 'default'; + } if (standardCursors.includes(type)) { // Standard css cursor cursor = type; @@ -570,7 +581,7 @@ function environment(p5, fn, lifecycles){ * @alt * This example does not render anything. */ - fn.displayWidth = screen.width; + fn.displayWidth = isBrowser ? window.screen.width : 0; /** * A `Number` variable that stores the height of the screen display. @@ -598,7 +609,7 @@ function environment(p5, fn, lifecycles){ * @alt * This example does not render anything. */ - fn.displayHeight = screen.height; + fn.displayHeight = isBrowser ? window.screen.height : 0; /** * A `Number` variable that stores the width of the browser's viewport. @@ -724,21 +735,11 @@ function environment(p5, fn, lifecycles){ }; function getWindowWidth() { - return ( - window.innerWidth || - (document.documentElement && document.documentElement.clientWidth) || - (document.body && document.body.clientWidth) || - 0 - ); + return isBrowser ? document.documentElement.clientWidth : 0; } function getWindowHeight() { - return ( - window.innerHeight || - (document.documentElement && document.documentElement.clientHeight) || - (document.body && document.body.clientHeight) || - 0 - ); + return isBrowser ? document.documentElement.clientHeight : 0; } /** @@ -798,7 +799,6 @@ function environment(p5, fn, lifecycles){ * } */ fn.fullscreen = function(val) { - // p5._validateParameters('fullscreen', arguments); // no arguments, return fullscreen or not if (typeof val === 'undefined') { return ( @@ -869,7 +869,6 @@ function environment(p5, fn, lifecycles){ * @returns {Number} current pixel density of the sketch. */ fn.pixelDensity = function(val) { - // p5._validateParameters('pixelDensity', arguments); let returnValue; if (typeof val === 'number') { if (val !== this._renderer._pixelDensity) { diff --git a/src/core/friendly_errors/fes_core.js b/src/core/friendly_errors/fes_core.js index 8962745918..21b6f60310 100644 --- a/src/core/friendly_errors/fes_core.js +++ b/src/core/friendly_errors/fes_core.js @@ -1,6 +1,5 @@ /** * @for p5 - * @requires core * * This is the main file for the Friendly Error System (FES), containing * the core as well as miscellaneous functionality of the FES. Here is a @@ -26,7 +25,7 @@ import { translator } from '../internationalization'; import errorTable from './browser_errors'; import * as contants from '../constants'; -function fesCore(p5, fn){ +function fesCore(p5, fn, lifecycles){ // p5.js blue, p5.js orange, auto dark green; fallback p5.js darkened magenta // See testColors below for all the color codes and names const typeColors = ['#2D7BB6', '#EE9900', '#4DB200', '#C83C00']; @@ -972,9 +971,11 @@ function fesCore(p5, fn){ p5._fesLogger = null; p5._fesLogCache = {}; - window.addEventListener('load', checkForUserDefinedFunctions, false); - window.addEventListener('error', p5._fesErrorMonitor, false); - window.addEventListener('unhandledrejection', p5._fesErrorMonitor, false); + lifecycles.presetup = function () { + window.addEventListener('load', checkForUserDefinedFunctions, false); + window.addEventListener('error', p5._fesErrorMonitor, false); + window.addEventListener('unhandledrejection', p5._fesErrorMonitor, false); + }; /** * Prints out all the colors in the color pallete with white text. @@ -1134,7 +1135,7 @@ function fesCore(p5, fn){ // Exposing this primarily for unit testing. fn._helpForMisusedAtTopLevelCode = helpForMisusedAtTopLevelCode; - if (document.readyState !== 'complete') { + if (typeof document !== 'undefined' && document.readyState !== 'complete') { window.addEventListener('error', helpForMisusedAtTopLevelCode, false); // Our job is only to catch ReferenceErrors that are thrown when @@ -1150,5 +1151,5 @@ function fesCore(p5, fn){ export default fesCore; if (typeof p5 !== 'undefined') { - fesCore(p5, p5.prototype); + p5.registerAddon(fesCore); } diff --git a/src/core/friendly_errors/file_errors.js b/src/core/friendly_errors/file_errors.js index 8f212c8355..75b8462b72 100644 --- a/src/core/friendly_errors/file_errors.js +++ b/src/core/friendly_errors/file_errors.js @@ -1,6 +1,5 @@ /** * @for p5 - * @requires core */ import { translator } from '../internationalization'; diff --git a/src/core/friendly_errors/param_validator.js b/src/core/friendly_errors/param_validator.js index 5fa4e73151..6277304168 100644 --- a/src/core/friendly_errors/param_validator.js +++ b/src/core/friendly_errors/param_validator.js @@ -1,6 +1,5 @@ /** * @for p5 - * @requires core */ import * as constants from '../constants.js'; import { z } from 'zod/v4'; @@ -64,7 +63,7 @@ function validateParams(p5, fn, lifecycles) { 'Boolean': z.boolean(), 'Function': z.function(), 'Integer': z.number().int(), - 'Number': z.number(), + 'Number': z.union([z.number(), z.literal(Infinity), z.literal(-Infinity)]), 'Object': z.object({}), 'String': z.string() }; @@ -411,33 +410,44 @@ function validateParams(p5, fn, lifecycles) { // of any of them. In this case, aggregate all possible types and print // a friendly error message that indicates what the expected types are at // which position (position is not 0-indexed, for accessibility reasons). + const processUnionError = error => { const expectedTypes = new Set(); let actualType; - error.errors.forEach(err => { - const issue = err[0]; - if (issue) { - if (!actualType) { - actualType = issue.message; - } + const collectIssue = issue => { + if (!issue) return; + if (!actualType) { + actualType = issue.message; + } - if (issue.code === 'invalid_type') { - actualType = issue.message.split(', received ')[1]; - expectedTypes.add(issue.expected); - } - // The case for constants. Since we don't want to print out the actual - // constant values in the error message, the error message will - // direct users to the documentation. - else if (issue.code === 'invalid_value') { + if (issue.code === 'invalid_type') { + actualType = issue.message.split(', received ')[1]; + expectedTypes.add(issue.expected); + } + // The case for constants. Since we don't want to print out the actual + // constant values in the error message, the error message will + // direct users to the documentation. + else if (issue.code === 'invalid_value') { + if (Array.isArray(issue.values) && issue.values.every(v => v === Infinity || v === -Infinity)) { + expectedTypes.add('number'); + } else { expectedTypes.add('constant (please refer to documentation for allowed values)'); actualType = args[error.path[0]]; - } else if (issue.code === 'custom') { - const match = issue.message.match(/Input not instance of (\w+)/); - if (match) expectedTypes.add(match[1]); - actualType = undefined; } + } else if (issue.code === 'custom') { + const match = issue.message.match(/Input not instance of (\w+)/); + if (match) expectedTypes.add(match[1]); + actualType = undefined; + } else if (issue.code === 'invalid_union') { + issue.errors.forEach(nestedErr => { + nestedErr.forEach(nestedIssue => collectIssue(nestedIssue)); + }); } + }; + + error.errors.forEach(err => { + err.forEach(issue => collectIssue(issue)); }); if (expectedTypes.size > 0) { @@ -458,6 +468,7 @@ function validateParams(p5, fn, lifecycles) { return message; }; + switch (currentError.code) { case 'invalid_union': { processUnionError(currentError); @@ -595,6 +606,9 @@ function validateParams(p5, fn, lifecycles) { function(target, { kind, name }){ if(kind === 'method'){ return function(...args){ + if (p5.disableFriendlyErrors) { + return target.apply(this, args); + } const wasInternalCall = this._isUserCall; this._isUserCall = true; try { @@ -618,5 +632,5 @@ function validateParams(p5, fn, lifecycles) { export default validateParams; if (typeof p5 !== 'undefined') { - validateParams(p5, p5.prototype); + p5.registerAddon(validateParams); } diff --git a/src/core/friendly_errors/stacktrace.js b/src/core/friendly_errors/stacktrace.js index 1777abac41..1c60713f9f 100644 --- a/src/core/friendly_errors/stacktrace.js +++ b/src/core/friendly_errors/stacktrace.js @@ -1,6 +1,5 @@ /** * @for p5 - * @requires core */ // Borrow from stacktracejs https://github.com/stacktracejs/stacktrace.js with // minor modifications. The license for the same and the code is included below diff --git a/src/core/helpers.js b/src/core/helpers.js index 0bc0ddbe3d..277fbf2f5f 100644 --- a/src/core/helpers.js +++ b/src/core/helpers.js @@ -1,5 +1,4 @@ /** - * @requires constants */ import * as constants from './constants'; @@ -16,7 +15,7 @@ function modeAdjust(a, b, c, d, mode) { if (mode === constants.CORNER) { // CORNER mode already corresponds to a bounding box (top-left corner, width, height). - // For negative widhts or heights, the absolute value is used. + // For negative widths or heights, the absolute value is used. bbox = { x: a, y: b, @@ -27,7 +26,7 @@ function modeAdjust(a, b, c, d, mode) { } else if (mode === constants.CORNERS) { // CORNERS mode uses two opposite corners, in any configuration. - // Make sure to get the top left corner by using the minimum of the x and y coordniates. + // Make sure to get the top left corner by using the minimum of the x and y coordinates. bbox = { x: Math.min(a, c), y: Math.min(b, d), diff --git a/src/core/init.js b/src/core/init.js index 764437b1ff..0ba781e406 100644 --- a/src/core/init.js +++ b/src/core/init.js @@ -13,6 +13,7 @@ import { initialize as initTranslator } from './internationalization'; * @return {Undefined} */ export const _globalInit = () => { + if(typeof window === 'undefined') return; // Could have been any property defined within the p5 constructor. // If that property is already a part of the global object, // this code has already run before, likely due to a duplicate import @@ -40,17 +41,20 @@ export const _globalInit = () => { }; // make a promise that resolves when the document is ready -export const waitForDocumentReady = () => - new Promise((resolve, reject) => { - // if the page is ready, initialize p5 immediately - if (document.readyState === 'complete') { - resolve(); - // if the page is still loading, add an event listener - // and initialize p5 as soon as it finishes loading - } else { - window.addEventListener('load', resolve, false); - } - }); +export const waitForDocumentReady = () =>{ + if(typeof document !== 'undefined'){ + return new Promise((resolve, reject) => { + // if the page is ready, initialize p5 immediately + if (document.readyState === 'complete') { + resolve(); + // if the page is still loading, add an event listener + // and initialize p5 as soon as it finishes loading + } else { + window.addEventListener('load', resolve, false); + } + }); + } +}; // only load translations if we're using the full, un-minified library export const waitingForTranslator = diff --git a/src/core/legacy.js b/src/core/legacy.js index bf05901333..6253b8894e 100644 --- a/src/core/legacy.js +++ b/src/core/legacy.js @@ -1,6 +1,5 @@ /** * @for p5 - * @requires core * These are functions that are part of the Processing API but are not part of * the p5.js API. In some cases they have a new name, in others, they are * removed completely. Not all unsupported Processing functions are listed here diff --git a/src/core/main.js b/src/core/main.js index 4ce9d91c55..00e536d900 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -2,7 +2,6 @@ * @module Structure * @submodule Structure * @for p5 - * @requires constants */ import * as constants from './constants'; @@ -35,6 +34,7 @@ class p5 { // This is a pointer to our global mode p5 instance, if we're in // global mode. static instance = null; + static sketchCount = 0; static lifecycleHooks = { presetup: [], postsetup: [], @@ -50,7 +50,7 @@ class p5 { constructor(sketch, node) { // Apply addon defined decorations if(p5.decorations.size > 0){ - decorateClass(p5, p5.decorations); + decorateClass(p5, p5.decorations, 'p5'); p5.decorations.clear(); } @@ -125,19 +125,24 @@ class p5 { const blurHandler = () => { this.focused = false; }; - window.addEventListener('focus', focusHandler); - window.addEventListener('blur', blurHandler); - p5.lifecycleHooks.remove.push(function() { - window.removeEventListener('focus', focusHandler); - window.removeEventListener('blur', blurHandler); - }); - - // Initialization complete, start runtime - if (document.readyState === 'complete') { + + if(typeof window !== 'undefined'){ + window.addEventListener('focus', focusHandler); + window.addEventListener('blur', blurHandler); + p5.lifecycleHooks.remove.push(function() { + window.removeEventListener('focus', focusHandler); + window.removeEventListener('blur', blurHandler); + }); + + // Initialization complete, start runtime + if (document.readyState === 'complete') { + this.#_start(); + } else { + this._startListener = this.#_start.bind(this); + window.addEventListener('load', this._startListener, false); + } + }else{ this.#_start(); - } else { - this._startListener = this.#_start.bind(this); - window.addEventListener('load', this._startListener, false); } } @@ -224,15 +229,17 @@ class p5 { // Always create a default canvas. // Later on if the user calls createCanvas, this default one // will be replaced - this.createCanvas( - 100, - 100, - constants.P2D - ); + if(typeof window !== 'undefined'){ + this.createCanvas( + 100, + 100, + constants.P2D + ); + } // Record the time when setup starts. millis() will start at 0 within // setup, but this isn't documented, locked-in behavior yet. - this._millisStart = window.performance.now(); + this._millisStart = globalThis.performance.now(); const context = this._isGlobal ? window : this; if (typeof context.setup === 'function') { @@ -240,21 +247,23 @@ class p5 { } if (this.hitCriticalError) return; - const canvases = document.getElementsByTagName('canvas'); - for (const k of canvases) { - // Apply touchAction = 'none' to canvases to prevent scrolling - // when dragging on canvas elements - k.style.touchAction = 'none'; - - // unhide any hidden canvases that were created - if (k.dataset.hidden === 'true') { - k.style.visibility = ''; - delete k.dataset.hidden; + if(typeof document !== 'undefined'){ + const canvases = document.getElementsByTagName('canvas'); + for (const k of canvases) { + // Apply touchAction = 'none' to canvases to prevent scrolling + // when dragging on canvas elements + k.style.touchAction = 'none'; + + // unhide any hidden canvases that were created + if (k.dataset.hidden === 'true') { + k.style.visibility = ''; + delete k.dataset.hidden; + } } } - this._lastTargetFrameTime = window.performance.now(); - this._lastRealFrameTime = window.performance.now(); + this._lastTargetFrameTime = globalThis.performance.now(); + this._lastRealFrameTime = globalThis.performance.now(); this._setupDone = true; if (this._accessibleOutputs.grid || this._accessibleOutputs.text) { this._updateAccsOutput(); @@ -265,7 +274,7 @@ class p5 { // Record the time when the draw loop starts so that millis() starts at 0 // when the draw loop begins. - this._millisStart = window.performance.now(); + this._millisStart = globalThis.performance.now(); } // While '#_draw' here is async, it is not awaited as 'requestAnimationFrame' @@ -275,7 +284,7 @@ class p5 { // and 'postdraw'. async _draw(requestAnimationFrameTimestamp) { if (this.hitCriticalError) return; - const now = requestAnimationFrameTimestamp || window.performance.now(); + const now = requestAnimationFrameTimestamp || globalThis.performance.now(); const timeSinceLastFrame = now - this._lastTargetFrameTime; const targetTimeBetweenFrames = 1000 / this._targetFrameRate; @@ -317,9 +326,10 @@ class p5 { // get notified the next time the browser gives us // an opportunity to draw. if (this._loop) { - this._requestAnimId = window.requestAnimationFrame( - this._draw.bind(this) - ); + const boundDraw = this._draw.bind(this); + this._requestAnimId = typeof window !== 'undefined' ? + window.requestAnimationFrame(boundDraw) : + setImmediate(boundDraw); } } @@ -521,7 +531,6 @@ function createBindGlobal(instance) { // Generic function to decorate classes function decorateClass(Target, decorations, path){ - path ??= Target.name; // Static properties for(const key in Target){ if(!key.startsWith('_')){ @@ -673,6 +682,7 @@ export default p5; * * @method setup * @for p5 + * @return {void|Promise} * * @example * function setup() { diff --git a/src/core/p5.Renderer.js b/src/core/p5.Renderer.js index c0fe4a6c02..ae5082644a 100644 --- a/src/core/p5.Renderer.js +++ b/src/core/p5.Renderer.js @@ -45,6 +45,11 @@ class Renderer { rectMode: constants.CORNER, ellipseMode: constants.CENTER, strokeWeight: 1, + bezierOrder: 3, + splineProperties: new ClonableObject({ + ends: constants.INCLUDE, + tightness: 0 + }), textFont: { family: 'sans-serif' }, textLeading: 15, @@ -52,15 +57,8 @@ class Renderer { textSize: 12, textAlign: constants.LEFT, textBaseline: constants.BASELINE, - bezierOrder: 3, - splineProperties: new ClonableObject({ - ends: constants.INCLUDE, - tightness: 0 - }), textWrap: constants.WORD, - - // added v2.0 - fontStyle: constants.NORMAL, // v1: textStyle + fontStyle: constants.NORMAL, // v1: was textStyle fontStretch: constants.NORMAL, fontWeight: constants.NORMAL, lineHeight: constants.NORMAL, @@ -73,12 +71,14 @@ class Renderer { this._isMainCanvas = isMainCanvas; this.pixels = []; + const defaultRatio = typeof window !== 'undefined' ? + Math.ceil(window.devicePixelRatio) : + 1; if (isMainCanvas) { - this._pixelDensity = Math.ceil(window.devicePixelRatio) || 1; + this._pixelDensity = defaultRatio; } else { - const parentDensity = pInst._pInst?._renderer?._pixelDensity; - this._pixelDensity = parentDensity || Math.ceil(window.devicePixelRatio) || 1; + this._pixelDensity = parentDensity || defaultRatio; } this.width = w; @@ -135,7 +135,7 @@ class Renderer { // and push it into the push pop stack push() { this._pushPopDepth++; - this._pushPopStack.push(this.states.getDiff()); + this._pushPopStack.push(this.states.takeDiff()); } // Pop the previous states out of the push pop stack and @@ -330,9 +330,12 @@ class Renderer { } fill(...args) { - this.states.setValue('fillSet', true); - this.states.setValue('fillColor', this._pInst.color(...args)); - this.updateShapeVertexProperties(); + if (args.length > 0) { + this.states.setValue('fillSet', true); + this.states.setValue('fillColor', this._pInst.color(...args)); + this.updateShapeVertexProperties(); + } + return this.states.fillColor; } noFill() { @@ -340,14 +343,16 @@ class Renderer { } strokeWeight(w) { - if (w === undefined) { + if (typeof w === 'undefined') { return this.states.strokeWeight; - } else { - this.states.setValue('strokeWeight', w); } + this.states.setValue('strokeWeight', w); } stroke(...args) { + if (args.length === 0) { + return this.states.strokeColor; + } this.states.setValue('strokeSet', true); this.states.setValue('strokeColor', this._pInst.color(...args)); this.updateShapeVertexProperties(); diff --git a/src/core/p5.Renderer2D.js b/src/core/p5.Renderer2D.js index fbe5747449..e417497af1 100644 --- a/src/core/p5.Renderer2D.js +++ b/src/core/p5.Renderer2D.js @@ -11,9 +11,7 @@ import { Matrix } from '../math/p5.Matrix'; import { PrimitiveToPath2DConverter } from '../shape/custom_shapes'; import { DefaultFill, textCoreConstants } from '../type/textCore'; - const styleEmpty = 'rgba(0,0,0,0)'; -// const alphaThreshold = 0.00125; // minimum visible class Renderer2D extends Renderer { constructor(pInst, w, h, isMainCanvas, elt, attributes = {}) { @@ -30,7 +28,9 @@ class Renderer2D extends Renderer { this.canvas.style.display = 'none'; } - this.elt.id = 'defaultCanvas0'; + if(!this.elt.id){ + this.elt.id = `defaultCanvas${p5.sketchCount++}`; + } this.elt.classList.add('p5Canvas'); // Extend renderer with methods of p5.Element with getters @@ -166,18 +166,18 @@ class Renderer2D extends Renderer { ////////////////////////////////////////////// background(...args) { + if (args.length === 0) { + return this;// setter with no args does nothing + } this.push(); this.resetMatrix(); - if (args[0] instanceof Image) { + const img = args[0]; if (args[1] >= 0) { // set transparency of background - const img = args[0]; this.drawingContext.globalAlpha = args[1] / 255; - this._pInst.image(img, 0, 0, this.width, this.height); - } else { - this._pInst.image(args[0], 0, 0, this.width, this.height); } + this._pInst.image(img, 0, 0, this.width, this.height); } else { // create background rect const color = this._pInst.color(...args); @@ -202,6 +202,8 @@ class Renderer2D extends Renderer { } } this.pop(); + + return this; } clear() { @@ -214,6 +216,9 @@ class Renderer2D extends Renderer { fill(...args) { super.fill(...args); const color = this.states.fillColor; + if (args.length === 0) { + return color; // getter + } this._setFill(color.toString()); // Add accessible outputs if the method exists; on success, @@ -226,6 +231,9 @@ class Renderer2D extends Renderer { stroke(...args) { super.stroke(...args); const color = this.states.strokeColor; + if (args.length === 0) { + return color; // getter + } this._setStroke(color.toString()); // Add accessible outputs if the method exists; on success, @@ -279,10 +287,10 @@ class Renderer2D extends Renderer { this.clipPath.closePath(); } else { if (this.states.fillColor) { - this.drawingContext.fill(visitor.path); + this.drawingContext.fill(visitor.fillPath || visitor.path); } if (this.states.strokeColor) { - this.drawingContext.stroke(visitor.path); + this.drawingContext.stroke(visitor.strokePath || visitor.path); } } } @@ -484,6 +492,9 @@ class Renderer2D extends Renderer { ////////////////////////////////////////////// blendMode(mode) { + if (typeof mode === 'undefined') { // getter + return this._cachedBlendMode; + } if (mode === constants.SUBTRACT) { console.warn('blendMode(SUBTRACT) only works in WEBGL mode.'); } else if ( @@ -661,106 +672,35 @@ class Renderer2D extends Renderer { * start <= stop < start + TWO_PI */ arc(x, y, w, h, start, stop, mode) { - const ctx = this.drawingContext; - const rx = w / 2.0; - const ry = h / 2.0; - const epsilon = 0.00001; // Smallest visible angle on displays up to 4K. - let arcToDraw = 0; - const curves = []; - - const centerX = x + w / 2, - centerY = y + h / 2, - radiusX = w / 2, - radiusY = h / 2; - if (this._clipping) { - const tempPath = new Path2D(); - tempPath.ellipse(centerX, centerY, radiusX, radiusY, 0, start, stop); - const currentTransform = this.drawingContext.getTransform(); - const clipBaseTransform = this._clipBaseTransform.inverse(); - const relativeTransform = clipBaseTransform.multiply(currentTransform); - this.clipPath.addPath(tempPath, relativeTransform); - return this; - } - // Determines whether to add a line to the center, which should be done - // when the mode is PIE or default; as well as when the start and end - // angles do not form a full circle. - const createPieSlice = ! ( - mode === constants.CHORD || - mode === constants.OPEN || - (stop - start) % constants.TWO_PI === 0 + const shape = new p5.Shape({ position: new p5.Vector(0, 0) }); + shape.beginShape(); + shape.arcPrimitive( + x, + y, + w, + h, + start, + stop, + mode ); - - // Fill curves - if (this.states.fillColor) { - ctx.beginPath(); - ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, start, stop); - if (createPieSlice) ctx.lineTo(centerX, centerY); - ctx.closePath(); - ctx.fill(); - } - - // Stroke curves - if (this.states.strokeColor) { - ctx.beginPath(); - ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, start, stop); - - if (mode === constants.PIE && createPieSlice) { - // In PIE mode, stroke is added to the center and back to path, - // unless the pie forms a complete ellipse (see: createPieSlice) - ctx.lineTo(centerX, centerY); - } - - if (mode === constants.PIE || mode === constants.CHORD) { - // Stroke connects back to path begin for both PIE and CHORD - ctx.closePath(); - } - ctx.stroke(); - } + shape.endShape(); + this.drawShape(shape); return this; } ellipse(args) { - const ctx = this.drawingContext; - const doFill = !!this.states.fillColor, - doStroke = this.states.strokeColor; const x = parseFloat(args[0]), y = parseFloat(args[1]), w = parseFloat(args[2]), h = parseFloat(args[3]); - if (doFill && !doStroke) { - if (this._getFill() === styleEmpty) { - return this; - } - } else if (!doFill && doStroke) { - if (this._getStroke() === styleEmpty) { - return this; - } - } - const centerX = x + w / 2, - centerY = y + h / 2, - radiusX = w / 2, - radiusY = h / 2; - if (this._clipping) { - const tempPath = new Path2D(); - tempPath.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI); - const currentTransform = this.drawingContext.getTransform(); - const clipBaseTransform = this._clipBaseTransform.inverse(); - const relativeTransform = clipBaseTransform.multiply(currentTransform); - this.clipPath.addPath(tempPath, relativeTransform); - return this; - } - ctx.beginPath(); - ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI); - ctx.closePath(); - if (doFill) { - ctx.fill(); - } - if (doStroke) { - ctx.stroke(); - } + const shape = new p5.Shape({ position: new p5.Vector(0, 0) }); + shape.beginShape(); + shape.ellipsePrimitive(x,y,w,h); + shape.endShape(); + this.drawShape(shape); return this; } @@ -1004,6 +944,9 @@ class Renderer2D extends Renderer { ////////////////////////////////////////////// strokeCap(cap) { + if (typeof cap === 'undefined') { // getter + return this.drawingContext.lineCap; + } if ( cap === constants.ROUND || cap === constants.SQUARE || @@ -1015,6 +958,9 @@ class Renderer2D extends Renderer { } strokeJoin(join) { + if (typeof join === 'undefined') { // getter + return this.drawingContext.lineJoin; + } if ( join === constants.ROUND || join === constants.BEVEL || @@ -1027,7 +973,10 @@ class Renderer2D extends Renderer { strokeWeight(w) { super.strokeWeight(w); - if (typeof w === 'undefined' || w === 0) { + if (typeof w === 'undefined') { + return this.states.strokeWeight; + } + if (w === 0) { // hack because lineWidth 0 doesn't work this.drawingContext.lineWidth = 0.0001; } else { @@ -1090,16 +1039,22 @@ class Renderer2D extends Renderer { rotate(rad) { this.drawingContext.rotate(rad); + return this; } scale(x, y) { + // support passing objects with x,y properties (including p5.Vector) + if (typeof x === 'object' && 'x' in x && 'y' in x) { + y = x.y; + x = x.x; + } this.drawingContext.scale(x, y); return this; } translate(x, y) { - // support passing a vector as the 1st parameter - if (x instanceof p5.Vector) { + // support passing objects with x,y properties (including p5.Vector) + if (typeof x === 'object' && 'x' in x && 'y' in x) { y = x.y; x = x.x; } diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 4241a56157..d83df0170d 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -1,3 +1,8 @@ +/** + * @module 3D + * @for p5 + */ + import * as constants from "../core/constants"; import { Graphics } from "../core/p5.Graphics"; import { Renderer } from './p5.Renderer'; @@ -132,7 +137,7 @@ export class Renderer3D extends Renderer { this.states._useShininess = 1; this.states._useMetalness = 0; - this.states.tint = [255, 255, 255, 255]; + this.states.tint = null; this.states.constantAttenuation = 1; this.states.linearAttenuation = 0; @@ -155,6 +160,7 @@ export class Renderer3D extends Renderer { // clipping this._clipDepths = []; + this._textContextSavedStack = []; this._isClipApplied = false; this._stencilTestOn = false; @@ -216,6 +222,8 @@ export class Renderer3D extends Renderer { // Used by beginShape/endShape functions to construct a p5.Geometry this.shapeBuilder = new ShapeBuilder(this); + this._largeTessellationAcknowledged = false; + this.geometryBufferCache = new GeometryBufferCache(this); this.curStrokeCap = constants.ROUND; @@ -472,10 +480,6 @@ export class Renderer3D extends Renderer { * combining them with `buildGeometry()` once and then drawing that will run * faster than repeatedly drawing the individual pieces. * - * One can also draw shapes directly between - * beginGeometry() and - * endGeometry() instead of using a callback - * function. * @param {Function} callback A function that draws shapes. * @returns {p5.Geometry} The model that was built from the callback function. */ @@ -721,10 +725,10 @@ export class Renderer3D extends Renderer { this.states.setValue("enableLighting", false); //reset tint value for new frame - this.states.setValue("tint", [255, 255, 255, 255]); + this.states.setValue("tint", new Color([1,1,1,1])); //Clear depth every frame - this._resetBuffersBeforeDraw() + this._resetBuffersBeforeDraw(); } background(...args) { @@ -1322,6 +1326,15 @@ export class Renderer3D extends Renderer { return this; } + push() { + super.push() + const saved = !!(this.states.textFont?.font); + if (saved) { + this.textDrawingContext().save() + } + this._textContextSavedStack.push(saved); + } + pop(...args) { if ( this._clipDepths.length > 0 && @@ -1329,6 +1342,9 @@ export class Renderer3D extends Renderer { ) { this._clearClip(); } + if (this._textContextSavedStack.pop()) { + this.textDrawingContext().restore() + } super.pop(...args); this._applyStencilTestIfClipping(); } @@ -1486,7 +1502,10 @@ export class Renderer3D extends Renderer { // works differently and is global p5 state. If the p5 state has // been cleared, we also need to clear the value in uSampler to match. fillShader.setUniform("uSampler", this.states._tex || empty); - fillShader.setUniform("uTint", this.states.tint); + fillShader.setUniform( + "uTint", + this.states.tint?._getRGBA([255, 255, 255, 255]) ?? [255, 255, 255, 255] + ); fillShader.setUniform("uHasSetAmbient", this.states._hasSetAmbient); fillShader.setUniform("uAmbientMatColor", this.states.curAmbientColor); @@ -1985,8 +2004,598 @@ export class Renderer3D extends Renderer { } } +const webGPUAddonMessage = 'Add the WebGPU add-on to your project and pass WEBGPU as the last argument to createCanvas.'; + function renderer3D(p5, fn) { p5.Renderer3D = Renderer3D; + + ShapeBuilder.prototype.friendlyErrorsDisabled = function() { + return Boolean(p5.disableFriendlyErrors); + }; + + /** + * Creates a `p5.StorageBuffer`, which is + * a block of data that shaders can read from, and compute shaders + * can also write to. This is only available in WebGPU mode. + * + * To read or write the data inside a shader, use + * `uniformStorage()`. To update its contents + * from JavaScript, call `.update()` + * on the result with new data. + * + * Pass an array of objects to store a list of items, each with named + * properties. The properties can be numbers, arrays of numbers, vectors + * created with `createVector()`, or colors + * created with `color()`. Inside the shader, each + * item is accessed by index, and its properties are available by name. + * + * ```js example + * let instanceData; + * let instancesShader; + * let instance; + * let count = 5; + * + * async function setup() { + * await createCanvas(200, 200, WEBGPU); + * + * let data = []; + * for (let i = 0; i < count; i++) { + * data.push({ + * position: createVector( + * random(-1, 1) * width / 2, + * random(-1, 1) * height / 2, + * 0, + * ), + * color: color( + * random(255), + * random(255), + * random(255) + * ) + * }); + * } + * instanceData = createStorage(data); + * instance = buildGeometry(drawInstance); + * instancesShader = buildMaterialShader(drawInstances); + * describe('Five spheres at random positions, each a different random color.'); + * } + * + * function drawInstance() { + * sphere(15); + * } + * + * function drawInstances() { + * let data = uniformStorage(instanceData); + * let itemColor = sharedVec4(); + * + * worldInputs.begin(); + * let item = data[instanceID()]; + * itemColor = item.color; + * worldInputs.position += item.position; + * worldInputs.end(); + * + * finalColor.begin(); + * finalColor.set(itemColor); + * finalColor.end(); + * } + * + * function draw() { + * background(220); + * lights(); + * noStroke(); + * shader(instancesShader); + * model(instance, count); + * } + * ``` + * + * You can also store a plain list of numbers by passing an array of numbers. + * Inside the shader, each number is accessed by index directly. To create an + * empty list to be filled in by a compute shader, pass a count instead. + * + * ```js example + * let cells; + * let nextCells; + * let gameShader; + * let displayShader; + * const W = 100; + * const H = 100; + * + * async function setup() { + * await createCanvas(100, 100, WEBGPU); + * + * let initial = new Float32Array(W * H); + * for (let i = 0; i < initial.length; i++) { + * initial[i] = random() > 0.7 ? 1 : 0; + * } + * cells = createStorage(initial); + * nextCells = createStorage(W * H); + * + * gameShader = buildComputeShader(simulate); + * displayShader = buildFilterShader(display); + * describe('An animated Game of Life simulation displayed as black and white pixels.'); + * } + * + * function simulate() { + * let current = uniformStorage(() => cells); + * let next = uniformStorage(() => nextCells); + * let w = uniformInt(() => W); + * let h = uniformInt(() => H); + * let x = index.x; + * let y = index.y; + * + * let n = 0; + * for (let dy = -1; dy <= 1; dy++) { + * for (let dx = -1; dx <= 1; dx++) { + * if (dx != 0 || dy != 0) { + * let nx = (x + dx + w) % w; + * let ny = (y + dy + h) % h; + * n += current[ny * w + nx]; + * } + * } + * } + * + * let alive = current[y * w + x]; + * let nextOutput = 0; + * if (alive == 1) { + * if (abs(n - 2) < 0.1 || abs(n - 3) < 0.1) { + * nextOutput = 1; + * } + * } else { + * if (abs(n - 3) < 0.1) { + * nextOutput = 1; + * } + * } + * next[y * w + x] = nextOutput; + * } + * + * function display() { + * let data = uniformStorage(() => cells); + * let w = uniformInt(() => W); + * let h = uniformInt(() => H); + * + * filterColor.begin(); + * let x = floor(filterColor.texCoord.x * w); + * let y = floor(filterColor.texCoord.y * h); + * let alive = data[y * w + x]; + * filterColor.set([alive, alive, alive, 1]); + * filterColor.end(); + * } + * + * function draw() { + * compute(gameShader, W, H); + * [nextCells, cells] = [cells, nextCells]; + * filter(displayShader); + * } + * ``` + * + * @method createStorage + * @submodule p5.strands + * @beta + * @webgpu + * @webgpuOnly + * @param {Number|Array|Float32Array|Object[]} dataOrCount Either a number specifying the count of floats, + * an array/Float32Array of floats, or an array of objects describing struct elements. + * @returns {p5.StorageBuffer} A storage buffer. + */ + fn.createStorage = function (dataOrCount) { + if (!this._renderer.createStorage) { + p5._friendlyError( + `createStorage() is only available with the WebGPU renderer. ${webGPUAddonMessage}`, + 'createStorage' + ); + return; + } + return this._renderer.createStorage(dataOrCount); + }; + + /** + * Returns the default shader used for compute operations. + * + * Calling `buildComputeShader(shaderFunction)` + * is equivalent to calling `baseComputeShader().modify(shaderFunction)`. + * + * Read the `buildComputeShader` reference or + * call `baseComputeShader().inspectHooks()` for more information on what you can do with + * the base compute shader. + * + * @method baseComputeShader + * @submodule p5.strands + * @beta + * @webgpu + * @webgpuOnly + * @returns {p5.Shader} The base compute shader. + */ + fn.baseComputeShader = function () { + if (!this._renderer.baseComputeShader) { + p5._friendlyError( + `baseComputeShader() is only available with the WebGPU renderer. ${webGPUAddonMessage}`, + 'baseComputeShader' + ); + return; + } + return this._renderer.baseComputeShader(); + }; + + /** + * Create a new compute shader using p5.strands. + * + * A compute shader lets you run many calculations all at once on your GPU. They + * are similar to a `compute()` + * and passing the shader in, along with the number of iterations in up to three + * dimensions. Use the `index` vector inside of your + * iteration function to refer to the current iteration of the loop. The `x`, `y`, + * and `z` properties will count up from zero to the count in each dimension passed + * into `compute`. + * + * A compute shader will read from and write to storage, which is often an array of + * numbers or objects. Use `createStorage` to construct + * initial data. Connect your iteration function to the storage by passing the storage + * into `uniformStorage`. + * + * Often, compute shaders are paired with `model(myGeometry, count)` + * to draw one instance per object in the storage, and a shader that uses + * `instanceID()` to position each instance. + * + * ```js example + * let particles; + * let computeShader; + * let displayShader; + * let instance; + * const numParticles = 100; + * + * async function setup() { + * await createCanvas(100, 100, WEBGPU); + * particles = createStorage(makeParticles(width / 2, height / 2)); + * computeShader = buildComputeShader(simulate); + * displayShader = buildMaterialShader(display); + * instance = buildGeometry(drawParticle); + * describe('100 orange particles shooting outward.'); + * } + * + * function makeParticles(x, y) { + * let data = []; + * for (let i = 0; i < numParticles; i++) { + * let angle = (i / numParticles) * TWO_PI; + * let speed = random(0.5, 2); + * data.push({ + * position: createVector(x, y), + * velocity: createVector(cos(angle) * speed, sin(angle) * speed), + * }); + * } + * return data; + * } + * + * function drawParticle() { + * sphere(2); + * } + * + * function simulate() { + * let data = uniformStorage(particles); + * let idx = index.x; + * data[idx].position = data[idx].position + data[idx].velocity; + * } + * + * function display() { + * let data = uniformStorage(particles); + * worldInputs.begin(); + * let pos = data[instanceID()].position; + * worldInputs.position.xy += pos - [width / 2, height / 2]; + * worldInputs.end(); + * } + * + * function draw() { + * background(30); + * if (frameCount % 60 === 0) { + * particles.update(makeParticles(random(width), random(height))); + * } + * compute(computeShader, numParticles); + * noStroke(); + * fill(255, 200, 50); + * shader(displayShader); + * model(instance, numParticles); + * } + * ``` + * + * ```js example + * let particles; + * let computeShader; + * let displayShader; + * let instance; + * const numParticles = 50; + * + * async function setup() { + * await createCanvas(100, 100, WEBGPU); + * + * let data = []; + * for (let i = 0; i < numParticles; i++) { + * data.push({ + * position: createVector( + * random(-40, 40), + * random(-40, 40) + * ), + * velocity: createVector( + * random(-1, 1), + * random(-1, 1) + * ), + * }); + * } + * particles = createStorage(data); + * + * computeShader = buildComputeShader(simulate); + * displayShader = buildMaterialShader(display); + * instance = buildGeometry(drawParticle); + * describe('50 white spheres bouncing around the canvas.'); + * } + * + * function drawParticle() { + * sphere(3); + * } + * + * function simulate() { + * let r = 3; + * let data = uniformStorage(particles); + * let idx = index.x; + * let pos = data[idx].position; + * let vel = data[idx].velocity; + * pos = pos + vel; + * if (pos.x > width/2 - r || pos.x < -height/2 + r) { + * vel.x = -vel.x; + * pos.x = clamp(pos.x, -width/2 + r, width/2 - r); + * } + * if (pos.y > height/2 - r || pos.y < -height/2 + r) { + * vel.y = -vel.y; + * pos.y = clamp(pos.y, -height/2 + r, height/2 - r); + * } + * data[idx].position = pos; + * data[idx].velocity = vel; + * } + * + * function display() { + * let data = uniformStorage(particles); + * worldInputs.begin(); + * let pos = data[instanceID()].position; + * worldInputs.position.xy += pos; + * worldInputs.end(); + * } + * + * function draw() { + * background(30); + * compute(computeShader, numParticles); + * noStroke(); + * fill(255); + * lights(); + * shader(displayShader); + * model(instance, numParticles); + * } + * ``` + * + * @method buildComputeShader + * @submodule p5.strands + * @beta + * @webgpu + * @webgpuOnly + * @param {Function} callback A function building a p5.strands compute shader. + * @returns {p5.Shader} The compute shader. + */ + fn.buildComputeShader = function (cb, context) { + if (!this._renderer.baseComputeShader) { + p5._friendlyError( + `buildComputeShader() is only available with the WebGPU renderer. ${webGPUAddonMessage}`, + 'buildComputeShader' + ); + return; + } + return this.baseComputeShader().modify(cb, context, { hook: 'iteration' }); + }; + + /** + * Dispatches a compute shader to run on the GPU. + * + * The first parameter, `shader`, is a compute shader created with + * `buildComputeShader`. + * + * Pass a number for `x` to run a simple loop. Inside the shader's iteration + * function, `index.x` will count up from 0 to + * that number. + * + * ```js example + * let particles; + * let computeShader; + * let displayShader; + * let instance; + * const numParticles = 50; + * + * async function setup() { + * await createCanvas(100, 100, WEBGPU); + * + * let data = []; + * for (let i = 0; i < numParticles; i++) { + * data.push({ + * position: createVector( + * random(-40, 40), + * random(-40, 40) + * ), + * velocity: createVector( + * random(-1, 1), + * random(-1, 1) + * ), + * }); + * } + * particles = createStorage(data); + * + * computeShader = buildComputeShader(simulate); + * displayShader = buildMaterialShader(display); + * instance = buildGeometry(drawParticle); + * describe('50 white spheres bouncing around the canvas.'); + * } + * + * function drawParticle() { + * sphere(3); + * } + * + * function simulate() { + * let r = 3; + * let data = uniformStorage(particles); + * let idx = index.x; + * let pos = data[idx].position; + * let vel = data[idx].velocity; + * pos = pos + vel; + * if (pos.x > width/2 - r || pos.x < -height/2 + r) { + * vel.x = -vel.x; + * pos.x = clamp(pos.x, -width/2 + r, width/2 - r); + * } + * if (pos.y > height/2 - r || pos.y < -height/2 + r) { + * vel.y = -vel.y; + * pos.y = clamp(pos.y, -height/2 + r, height/2 - r); + * } + * data[idx].position = pos; + * data[idx].velocity = vel; + * } + * + * function display() { + * let data = uniformStorage(particles); + * worldInputs.begin(); + * let pos = data[instanceID()].position; + * worldInputs.position.xy += pos; + * worldInputs.end(); + * } + * + * function draw() { + * background(30); + * compute(computeShader, numParticles); + * noStroke(); + * fill(255); + * lights(); + * shader(displayShader); + * model(instance, numParticles); + * } + * ``` + * + * You can also pass `y` and `z` to loop in up to three dimensions, using + * `index.y` and `index.z` to get the position in each. This is useful for + * working with 2D grids, like in the Game of Life example below. + * + * ```js example + * let cells; + * let nextCells; + * let gameShader; + * let displayShader; + * const W = 100; + * const H = 100; + * + * async function setup() { + * await createCanvas(100, 100, WEBGPU); + * + * let initial = new Float32Array(W * H); + * for (let i = 0; i < initial.length; i++) { + * initial[i] = random() > 0.7 ? 1 : 0; + * } + * cells = createStorage(initial); + * nextCells = createStorage(W * H); + * + * gameShader = buildComputeShader(simulate); + * displayShader = buildFilterShader(display); + * describe('An animated Game of Life simulation displayed as black and white pixels.'); + * } + * + * function simulate() { + * let current = uniformStorage(() => cells); + * let next = uniformStorage(() => nextCells); + * let w = uniformInt(() => W); + * let h = uniformInt(() => H); + * let x = index.x; + * let y = index.y; + * + * let n = 0; + * for (let dy = -1; dy <= 1; dy++) { + * for (let dx = -1; dx <= 1; dx++) { + * if (dx != 0 || dy != 0) { + * let nx = (x + dx + w) % w; + * let ny = (y + dy + h) % h; + * n += current[ny * w + nx]; + * } + * } + * } + * + * let alive = current[y * w + x]; + * let nextOutput = 0; + * if (alive == 1) { + * if (abs(n - 2) < 0.1 || abs(n - 3) < 0.1) { + * nextOutput = 1; + * } + * } else { + * if (abs(n - 3) < 0.1) { + * nextOutput = 1; + * } + * } + * next[y * w + x] = nextOutput; + * } + * + * function display() { + * let data = uniformStorage(() => cells); + * let w = uniformInt(() => W); + * let h = uniformInt(() => H); + * + * filterColor.begin(); + * let x = floor(filterColor.texCoord.x * w); + * let y = floor(filterColor.texCoord.y * h); + * let alive = data[y * w + x]; + * filterColor.set([alive, alive, alive, 1]); + * filterColor.end(); + * } + * + * function draw() { + * compute(gameShader, W, H); + * [nextCells, cells] = [cells, nextCells]; + * filter(displayShader); + * } + * ``` + * + * @method compute + * @submodule p5.strands + * @beta + * @webgpu + * @webgpuOnly + * @param {p5.Shader} shader The compute shader to run. + * @param {Number} x Number of invocations in the X dimension. + * @param {Number} [y=1] Number of invocations in the Y dimension. + * @param {Number} [z=1] Number of invocations in the Z dimension. + */ + fn.compute = function (shader, x, y, z) { + if (!this._renderer.compute) { + p5._friendlyError( + `compute() is only available with the WebGPU renderer. ${webGPUAddonMessage}`, + 'compute' + ); + return; + } + this._renderer.compute(shader, x, y, z); + }; + + /** + * Information about the current iteration of a compute shader. + * + * Use it inside a + * `buildComputeShader()` + * function to write a loop that runs in parallel on the GPU. + * + * `index` is a three-component vector with the current index + * across all dimensions passed to + * `compute()`. For example, use + * `index.x` to get the index when looping in one dimension. + * + * @property index + * @submodule p5.strands + * @beta + * @webgpu + * @webgpuOnly + */ } export default renderer3D; diff --git a/src/core/rendering.js b/src/core/rendering.js index dd9051508d..984837df61 100644 --- a/src/core/rendering.js +++ b/src/core/rendering.js @@ -45,10 +45,12 @@ function rendering(p5, fn){ * system variable to check what version is being used, or call * `setAttributes({ version: 1 })` to create a WebGL1 context. * + * Note: In WebGPU mode, you must `await` this function. + * * @method createCanvas * @param {Number} [width] width of the canvas. Defaults to 100. * @param {Number} [height] height of the canvas. Defaults to 100. - * @param {(P2D|WEBGL|P2DHDR|WEBGPU)} [renderer] either P2D, WEBGL, or WEBGPU. Defaults to `P2D`. + * @param {(P2D|WEBGL|P2DHDR)} [renderer] either P2D or WEBGL. Defaults to `P2D`. * @param {HTMLCanvasElement} [canvas] existing canvas element that should be used for the sketch. * @return {p5.Renderer} new `p5.Renderer` that holds the canvas. * @@ -106,6 +108,14 @@ function rendering(p5, fn){ * describe('A diagonal line drawn from top-left to bottom-right on a gray background.'); * } */ + /** + * @method createCanvas + * @param {Number} width + * @param {Number} height + * @param {WEBGPU} renderer + * @param {HTMLCanvasElement} [canvas] + * @return {Promise} + */ /** * @method createCanvas * @param {Number} [width] @@ -125,6 +135,14 @@ function rendering(p5, fn){ args.unshift(renderer); } + if (!renderers[selectedRenderer]) { + if (selectedRenderer === constants.WEBGPU) { + p5._friendlyError(`To create a WEBGPU canvas, remember to add the WebGPU add-on to your project.`); + } else { + p5._friendlyError(`We weren't able to find a renderer called ${selectedRenderer}.`); + } + } + // Init our graphics renderer if(this._renderer) this._renderer.remove(); this._renderer = new renderers[selectedRenderer](this, w, h, true, ...args); diff --git a/src/core/structure.js b/src/core/structure.js index 19c96c2156..6d17882f27 100644 --- a/src/core/structure.js +++ b/src/core/structure.js @@ -2,7 +2,6 @@ * @module Structure * @submodule Structure * @for p5 - * @requires core */ function structure(p5, fn){ diff --git a/src/core/transform.js b/src/core/transform.js index e8a6a32da3..4ff359d5a9 100644 --- a/src/core/transform.js +++ b/src/core/transform.js @@ -2,8 +2,6 @@ * @module Transform * @submodule Transform * @for p5 - * @requires core - * @requires constants */ function transform(p5, fn){ @@ -420,8 +418,7 @@ function transform(p5, fn){ */ fn.rotate = function(angle, axis) { // p5._validateParameters('rotate', arguments); - this._renderer.rotate(this._toRadians(angle), axis); - return this; + return this._renderer.rotate(this._toRadians(angle), axis); }; /** @@ -543,8 +540,7 @@ function transform(p5, fn){ fn.rotateX = function(angle) { this._assert3d('rotateX'); // p5._validateParameters('rotateX', arguments); - this._renderer.rotateX(this._toRadians(angle)); - return this; + return this._renderer.rotateX(this._toRadians(angle)); }; /** @@ -666,8 +662,7 @@ function transform(p5, fn){ fn.rotateY = function(angle) { this._assert3d('rotateY'); // p5._validateParameters('rotateY', arguments); - this._renderer.rotateY(this._toRadians(angle)); - return this; + return this._renderer.rotateY(this._toRadians(angle)); }; /** @@ -789,8 +784,7 @@ function transform(p5, fn){ fn.rotateZ = function(angle) { this._assert3d('rotateZ'); // p5._validateParameters('rotateZ', arguments); - this._renderer.rotateZ(this._toRadians(angle)); - return this; + return this._renderer.rotateZ(this._toRadians(angle)); }; /** @@ -966,9 +960,7 @@ function transform(p5, fn){ z = 1; } - this._renderer.scale(x, y, z); - - return this; + return this._renderer.scale(x, y, z); }; /** @@ -1274,11 +1266,10 @@ function transform(p5, fn){ fn.translate = function(x, y, z) { // p5._validateParameters('translate', arguments); if (this._renderer.isP3D) { - this._renderer.translate(x, y, z); + return this._renderer.translate(x, y, z); } else { - this._renderer.translate(x, y); + return this._renderer.translate(x, y); } - return this; }; /** diff --git a/src/data/local_storage.js b/src/data/local_storage.js index f625618c95..505b500d61 100644 --- a/src/data/local_storage.js +++ b/src/data/local_storage.js @@ -1,7 +1,6 @@ /** * @module Data * @submodule LocalStorage - * @requires core * * This module defines the p5 methods for working with local storage */ diff --git a/src/dom/dom.js b/src/dom/dom.js index 9f1dcb14c7..e975740ca0 100644 --- a/src/dom/dom.js +++ b/src/dom/dom.js @@ -13,7 +13,6 @@ * @module DOM * @submodule DOM * @for p5 - * @requires p5 */ import { Element } from './p5.Element'; diff --git a/src/events/acceleration.js b/src/events/acceleration.js index 7750f6099b..5370c60ed6 100644 --- a/src/events/acceleration.js +++ b/src/events/acceleration.js @@ -2,7 +2,6 @@ * @module Events * @submodule Acceleration * @for p5 - * @requires core * @main Events */ @@ -19,6 +18,10 @@ function acceleration(p5, fn, lifecycles){ signal: this._removeSignal }); } + + // Initialize device orientation value + this.deviceOrientation = typeof window !== 'undefined' && + window.innerWidth / window.innerHeight > 1.0 ? 'landscape' : 'portrait'; }; /** @@ -30,8 +33,7 @@ function acceleration(p5, fn, lifecycles){ * @property {(LANDSCAPE|PORTRAIT)} deviceOrientation * @readOnly */ - fn.deviceOrientation = - window.innerWidth / window.innerHeight > 1.0 ? 'landscape' : 'portrait'; + fn.deviceOrientation = 'landscape'; /** * The system variable accelerationX always contains the acceleration of the diff --git a/src/events/keyboard.js b/src/events/keyboard.js index eee2195f4d..0e51b0632c 100644 --- a/src/events/keyboard.js +++ b/src/events/keyboard.js @@ -2,7 +2,6 @@ * @module Events * @submodule Keyboard * @for p5 - * @requires core */ export function isCode(input) { const leftRightKeys = [ diff --git a/src/events/pointer.js b/src/events/pointer.js index b14b7c25d4..3fc8560302 100644 --- a/src/events/pointer.js +++ b/src/events/pointer.js @@ -2,8 +2,6 @@ * @module Events * @submodule Pointer * @for p5 - * @requires core - * @requires constants */ function pointer(p5, fn, lifecycles){ diff --git a/src/image/filterRenderer2D.js b/src/image/filterRenderer2D.js index d6bf72eed3..bd9da38d9b 100644 --- a/src/image/filterRenderer2D.js +++ b/src/image/filterRenderer2D.js @@ -17,7 +17,8 @@ import filterBaseVert from '../webgl/shaders/filters/base.vert'; import webgl2CompatibilityShader from '../webgl/shaders/webgl2Compatibility.glsl'; import { glslBackend } from '../webgl/strands_glslBackend'; import { getShaderHookTypes } from '../webgl/shaderHookUtils'; -import noiseGLSL from '../webgl/shaders/functions/noise3DGLSL.glsl'; +import randomGLSL from '../webgl/shaders/functions/randomGLSL.glsl'; +import randomVertGLSL from '../webgl/shaders/functions/randomVertGLSL.glsl'; import { makeFilterShader } from '../core/filterShaders'; class FilterRenderer2D { @@ -43,6 +44,7 @@ class FilterRenderer2D { console.error('WebGL not supported, cannot apply filter.'); return; } + this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); this.textures = new Map(); @@ -299,15 +301,23 @@ class FilterRenderer2D { 'vec4 getColor': `(FilterInputs inputs, in sampler2D canvasContent) { return getTexture(canvasContent, inputs.texCoord); }` - } + }, + hookAliases: { + 'getColor': ['filterColor'], + }, } ); } return this._baseFilterShader; } - getNoiseShaderSnippet() { - return noiseGLSL; + + getRandomFragmentShaderSnippet() { + return randomGLSL; + } + + getRandomVertexShaderSnippet() { + return randomVertGLSL; } /** @@ -372,7 +382,7 @@ class FilterRenderer2D { get canvasTexture() { if (!this._canvasTexture) { - this._canvasTexture = new Texture(this._renderer, this.parentRenderer.wrappedElt); + this._canvasTexture = new Texture(this._renderer, this.parentRenderer); } return this._canvasTexture; } diff --git a/src/image/image.js b/src/image/image.js index 707892fe42..2296f5a414 100644 --- a/src/image/image.js +++ b/src/image/image.js @@ -2,7 +2,6 @@ * @module Image * @submodule Image * @for p5 - * @requires core */ /** diff --git a/src/image/loading_displaying.js b/src/image/loading_displaying.js index 3b9ed18fd6..81f18b9a64 100644 --- a/src/image/loading_displaying.js +++ b/src/image/loading_displaying.js @@ -2,7 +2,6 @@ * @module Image * @submodule Loading & Displaying * @for p5 - * @requires core */ import canvas from '../core/helpers'; @@ -1138,6 +1137,9 @@ function loadingDisplaying(p5, fn){ * sets the alpha value. For example, `tint(255, 0, 0, 100)` will give images * a red tint and make them transparent. * + * Calling `tint()` without an argument returns the current tint as a + * p5.Color object. + * * @method tint * @param {Number} v1 red or hue value. * @param {Number} v2 green or saturation value. @@ -1242,10 +1244,18 @@ function loadingDisplaying(p5, fn){ * @method tint * @param {p5.Color} color the tint color */ + /** + * @method tint + * @return {p5.Color} the current tint color + */ fn.tint = function(...args) { - // p5._validateParameters('tint', args); - const c = this.color(...args); - this._renderer.states.setValue('tint', c._getRGBA([255, 255, 255, 255])); + if (args.length === 0) { + return this._renderer.states.tint; // getter + } + else { + this._renderer.states.setValue('tint', this.color(...args)); + return this; + } }; /** @@ -1279,6 +1289,7 @@ function loadingDisplaying(p5, fn){ */ fn.noTint = function() { this._renderer.states.setValue('tint', null); + return this; }; /** @@ -1310,6 +1321,8 @@ function loadingDisplaying(p5, fn){ * image() as the x- and y-coordinates of the image's * center. The next parameters are its width and height. * + * Calling `imageMode()` without an argument returns the current image mode, either `CORNER`, `CORNERS`, or `CENTER`. + * * @method imageMode * @param {(CORNER|CORNERS|CENTER)} mode either CORNER, CORNERS, or CENTER. * @@ -1373,8 +1386,15 @@ function loadingDisplaying(p5, fn){ * describe('A square image of a brick wall is drawn on a gray square.'); * } */ + /** + * @method imageMode + * @return {(CORNER|CORNERS|CENTER)} the current image mode + */ fn.imageMode = function(m) { // p5._validateParameters('imageMode', arguments); + if (typeof m === 'undefined') { // getter + return this._renderer.states.imageMode; + } if ( m === constants.CORNER || m === constants.CORNERS || diff --git a/src/image/p5.Image.js b/src/image/p5.Image.js index 80e5781524..6107cfaa5a 100644 --- a/src/image/p5.Image.js +++ b/src/image/p5.Image.js @@ -1,9 +1,6 @@ /** * @module Image * @submodule Image - * @requires core - * @requires constants - * @requires filters */ /** @@ -31,6 +28,9 @@ class Image { this.pixels = []; } + // This will get overwritten when exported as part of p5. + _friendlyError(_e) {} + /** * Gets or sets the pixel density for high pixel density displays. * @@ -1605,7 +1605,7 @@ class Image { props.displayIndex = index; this.drawingContext.putImageData(props.frames[index].image, 0, 0); } else { - p5._friendlyError( + this._friendlyError( 'Cannot set GIF to a frame number that is higher than total number of frames or below zero.', 'setFrame' ); @@ -2105,6 +2105,8 @@ function image(p5, fn){ */ p5.Image = Image; + Image.prototype._friendlyError = p5._friendlyError; + /** * The image's width in pixels. * diff --git a/src/image/pixels.js b/src/image/pixels.js index c1b7b4abe6..50e315166a 100644 --- a/src/image/pixels.js +++ b/src/image/pixels.js @@ -2,7 +2,6 @@ * @module Image * @submodule Pixels * @for p5 - * @requires core */ import Filters from './filters'; diff --git a/src/io/files.js b/src/io/files.js index 41af75877e..013f4803d5 100644 --- a/src/io/files.js +++ b/src/io/files.js @@ -2,7 +2,6 @@ * @module IO * @submodule Input * @for p5 - * @requires core */ import { Renderer } from '../core/p5.Renderer'; diff --git a/src/io/p5.Table.js b/src/io/p5.Table.js index 691742a462..47f5ccf273 100644 --- a/src/io/p5.Table.js +++ b/src/io/p5.Table.js @@ -1,7 +1,6 @@ /** * @module IO * @submodule Table - * @requires core */ import { stringify } from './csv'; diff --git a/src/io/p5.TableRow.js b/src/io/p5.TableRow.js index 03e59936ad..59e5d028ac 100644 --- a/src/io/p5.TableRow.js +++ b/src/io/p5.TableRow.js @@ -1,7 +1,6 @@ /** * @module IO * @submodule Table - * @requires core */ class TableRow { diff --git a/src/io/p5.XML.js b/src/io/p5.XML.js index e4e010f839..ba1cba3b9c 100644 --- a/src/io/p5.XML.js +++ b/src/io/p5.XML.js @@ -1,7 +1,6 @@ /** * @module IO * @submodule Input - * @requires core */ class XML { diff --git a/src/math/Matrices/MatrixNumjs.js b/src/math/Matrices/MatrixNumjs.js index 8ee35a7ee8..22ccf2e875 100644 --- a/src/math/Matrices/MatrixNumjs.js +++ b/src/math/Matrices/MatrixNumjs.js @@ -3,7 +3,6 @@ import { Vector } from '../p5.Vector'; import { MatrixInterface } from './MatrixInterface'; /** - * @requires constants * @todo see methods below needing further implementation. * future consideration: implement SIMD optimizations * when browser compatibility becomes available diff --git a/src/math/calculation.js b/src/math/calculation.js index a97e549854..82b788e788 100644 --- a/src/math/calculation.js +++ b/src/math/calculation.js @@ -2,7 +2,6 @@ * @module Math * @submodule Calculation * @for p5 - * @requires core */ function calculation(p5, fn){ diff --git a/src/math/index.js b/src/math/index.js index 2acb397b21..8e08dd88bd 100644 --- a/src/math/index.js +++ b/src/math/index.js @@ -4,6 +4,7 @@ import random from './random.js'; import trigonometry from './trigonometry.js'; import math from './math.js'; import vector from './p5.Vector.js'; +import vectorValidation from './patch-vector.js'; export default function(p5){ p5.registerAddon(calculation); @@ -12,4 +13,5 @@ export default function(p5){ p5.registerAddon(trigonometry); p5.registerAddon(math); p5.registerAddon(vector); + p5.registerAddon(vectorValidation); } diff --git a/src/math/math.js b/src/math/math.js index 9e513a7c36..71a6848e87 100644 --- a/src/math/math.js +++ b/src/math/math.js @@ -1,7 +1,6 @@ /** * @module Math * @for p5 - * @requires core */ function math(p5, fn) { @@ -38,7 +37,7 @@ function math(p5, fn) { * p5.Vector class. * * @method createVector - * @param {...Number} x Zero or more numbers, representing each component of the vector. + * @param {...Number} x List of numbers representing each component of the vector. * @return {p5.Vector} new p5.Vector object. * * @example @@ -92,20 +91,15 @@ function math(p5, fn) { * point(pos); * } */ - fn.createVector = function (x, y, z) { - if (arguments.length === 0) { - p5._friendlyError( - 'In 1.x, createVector() was a shortcut for createVector(0, 0, 0). In 2.x, p5.js has vectors of any dimension, so you must provide your desired number of zeros. Use createVector(0, 0) for a 2D vector and createVector(0, 0, 0) for a 3D vector.' - ); - } + fn.createVector = function (...args) { if (this instanceof p5) { - return new p5.Vector( - this._fromRadians.bind(this), - this._toRadians.bind(this), - ...arguments - ); + if (!this._boundFromRadians) { + this._boundFromRadians = this._fromRadians.bind(this); + this._boundToRadians = this._toRadians.bind(this); + } + return new p5.Vector(this._boundFromRadians, this._boundToRadians, ...args); } else { - return new p5.Vector(x, y, z); + return new p5.Vector(...args); } }; diff --git a/src/math/noise.js b/src/math/noise.js index 0105615fc8..fcc26a42d8 100644 --- a/src/math/noise.js +++ b/src/math/noise.js @@ -15,7 +15,6 @@ * @module Math * @submodule Noise * @for p5 - * @requires core */ function noise(p5, fn){ const PERLIN_YWRAPB = 4; @@ -66,13 +65,7 @@ function noise(p5, fn){ * three dimensions. These dimensions can be thought of as space, as in * `noise(x, y, z)`, or space and time, as in `noise(x, y, t)`. * - * @method noise - * @param {Number} x x-coordinate in noise space. - * @param {Number} [y] y-coordinate in noise space. - * @param {Number} [z] z-coordinate in noise space. - * @return {Number} Perlin noise value at specified coordinates. - * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -90,8 +83,9 @@ function noise(p5, fn){ * strokeWeight(5); * point(x, y); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -116,8 +110,9 @@ function noise(p5, fn){ * strokeWeight(5); * point(x, y); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -139,8 +134,9 @@ function noise(p5, fn){ * // Draw the line. * line(x, 0, x, y); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -167,8 +163,9 @@ function noise(p5, fn){ * line(x, 0, x, y); * } * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -197,8 +194,9 @@ function noise(p5, fn){ * * describe('A gray cloudy pattern.'); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -228,6 +226,43 @@ function noise(p5, fn){ * } * } * } + * ``` + * + * `noise()` can also be used in shaders with p5.strands, where it returns + * values in the range 0 to 1. The example below uses `noise()` inside a + * filter shader to create a cloud-like texture effect: + * + * ```js example + * let myFilter; + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * myFilter = buildFilterShader(shaderCallback); + * describe('A cloud-like noise pattern.'); + * } + * + * function shaderCallback() { + * filterColor.begin(); + * let coord = filterColor.texCoord; + * let t = millis() / 2000; + * // noise() returns values in the range 0 to 1. + * let mixFraction = noise(coord.x * 5, coord.y * 5, t); + * let darkBlue = [0.1, 0.1, 0.3, 1]; + * let lightBlue = [0.9, 0.9, 1, 1]; + * filterColor.set(mix(darkBlue, lightBlue, mixFraction)); + * filterColor.end(); + * } + * + * function draw() { + * filter(myFilter); + * } + * ``` + * + * @method noise + * @param {Number} x x-coordinate in noise space. + * @param {Number} [y] y-coordinate in noise space. + * @param {Number} [z] z-coordinate in noise space. + * @return {Number} Perlin noise value at specified coordinates. */ fn.noise = function(x, y = 0, z = 0) { if (perlin == null) { @@ -472,4 +507,4 @@ export default noise; if(typeof p5 !== 'undefined'){ noise(p5, p5.prototype); -} +} \ No newline at end of file diff --git a/src/math/p5.Matrix.js b/src/math/p5.Matrix.js index b6488f76d0..8923bb420c 100644 --- a/src/math/p5.Matrix.js +++ b/src/math/p5.Matrix.js @@ -1,6 +1,5 @@ /** * @module Math - * @requires constants * @todo see methods below needing further implementation. * future consideration: implement SIMD optimizations * when browser compatibility becomes available diff --git a/src/math/p5.Vector.js b/src/math/p5.Vector.js index 1f3df37f8a..4c1952a16b 100644 --- a/src/math/p5.Vector.js +++ b/src/math/p5.Vector.js @@ -1,89 +1,126 @@ /** * @module Math - * @requires constants */ import * as constants from '../core/constants'; -/// HELPERS FOR REMAINDER METHOD -const calculateRemainder2D = function (xComponent, yComponent) { - if (xComponent !== 0) { - this.x = this.x % xComponent; - } - if (yComponent !== 0) { - this.y = this.y % yComponent; +/** + * @private + * This function is used by binary vector operations to prioritize shorter vectors, + * and to emit a warning when lengths do not match. + */ +const prioritizeSmallerDimension = function (currentVectorDimension, args) { + const resultDimension = Math.min(currentVectorDimension, args.length); + if (Array.isArray(args) && currentVectorDimension !== args.length) { + console.warn( + 'When working with two vectors of different sizes, the smaller dimension is used. In this operation, both vector will be treated as ' + resultDimension + 'D vectors, and any additional values of the linger vector will be ignored.' + ); } - return this; + return resultDimension; }; -const calculateRemainder3D = function (xComponent, yComponent, zComponent) { - if (xComponent !== 0) { - this.x = this.x % xComponent; - } - if (yComponent !== 0) { - this.y = this.y % yComponent; - } - if (zComponent !== 0) { - this.z = this.z % zComponent; +/** + * @private + * In-place, shrinks an array to a dimension. + */ +const shrinkToDimension = function(arr, dim) { + while (arr.length > dim) { + arr.pop(); } - return this; -}; +} + class Vector { + /** + * The values of an N-dimensional vector. + * + * This array of numbers that represents the vector. + * Each number in the array corresponds to a different component of the vector, + * like its position in different directions (e.g., x, y, z). + * + * You can update the values of the entire vector to a new set of values. + * You need to provide an array of numbers, where each number represents a component + * of the vector (e.g., x, y, z). The length of the array will become the number of + * dimensions of the vector. + * + * You can add (`add()`), multiply (`mult()`), divide (`div()`), and subtract (`sub()`) + * vectors from each other, and calculate remainder (`rem()`). Only use these functions + * on vectors when they are the same size: both 2-dimensional, or both 3-dimensional. + * When an operation uses two vectors of different sizes, the smaller dimension will be + * used, any additional values of the longer vector will be ignored. + * + * You can multiply, divide, or calculate remainder of a vector with a single number. Then, + * the same operation will be done on each element of the vector. + * + * @type {Array} The array of values representing the vector. + * @throws Will throw an error if provided no arguments, or if the arguments + * are not all finity numbers + */ + values = []; + + /** + * @private + * Check for disabled friendly errors. + * This is overridden in the addon function to check the p5 instance. + */ + static friendlyErrorsDisabled() { + return true; + } + // This is how it comes in with createVector() // This check if the first argument is a function constructor(...args) { - let values = args; // .map(arg => arg || 0); + + if (args.length === 0) { + this._friendlyError( + 'Requires valid arguments.', 'p5.Vector' + ); + } + if (typeof args[0] === 'function') { this.isPInst = true; - this._fromRadians = args[0]; - this._toRadians = args[1]; - values = args.slice(2); // .map(arg => arg || 0); + this._fromRadians = args.shift(); + this._toRadians = args.shift(); } - let dimensions = values.length; // TODO: make default 3 if no arguments - if (dimensions === 0) { - this.dimensions = 2; - this._values = [0, 0, 0]; + + this.values = args; + if (Array.isArray(args)) { + for (let i = 0; i < args.length; i++) { + const v = args[i]; + if (typeof v !== 'number' || !Number.isFinite(v)) { + if (!Vector.friendlyErrorsDisabled()) { + this._friendlyError( + 'Arguments contain non-finite numbers', + 'p5.Vector' + ); + } + this.values = []; + break; + } + } } else { - this.dimensions = dimensions; - this._values = values; + this.values = []; } - } - /** - * Gets the values of the N-dimensional vector. - * - * This method returns an array of numbers that represent the vector. - * Each number in the array corresponds to a different component of the vector, - * like its position in different directions (e.g., x, y, z). - * - * @returns {Array} The array of values representing the vector. - */ - get values() { - return this._values; + // This property is here where duck typing (checking if obj.isVector) needs + // to be used over more standard type checking (obj instanceof Vector). This + // needs to happen where we are building multiple files, such as in p5.webgpu.js, + // where if we `import { Vector }` directly, it will be a separate copy of the + // Vector class from the one imported in the main p5.js bundle. + this.isVector = true; } + // This will get overwritten when exported as part of p5. + _friendlyError(_e) {} + + /** - * Sets the values of the vector. - * - * This method allows you to update the entire vector with a new set of values. - * You need to provide an array of numbers, where each number represents a component - * of the vector (e.g., x, y, z). The length of the array should match the number of - * dimensions of the vector. If the array is shorter, the missing components will be - * set to 0. If the array is longer, the extra values will be ignored. - * - * @param {Array} newValues - An array of numbers representing the new values for the vector. + * Gets how many dimensions the vector has. * + * @returns {Number} The number of dimensions. Can be 1, 2, or 3. */ - set values(newValues) { - let dimensions = newValues.length; - if (dimensions === 0) { - this.dimensions = 2; - this._values = [0, 0, 0]; - } else { - this.dimensions = dimensions; - this._values = newValues.slice(); - } + get dimensions(){ + return this.values.length; } /** @@ -96,7 +133,7 @@ class Vector { * @returns {Number} The x component of the vector. Returns 0 if the value is not defined. */ get x() { - return this._values[0] || 0; + return this.values[0] || 0; } /** @@ -117,10 +154,10 @@ class Vector { * get a value from a position that doesn't exist in the vector. */ getValue(index) { - if (index < this._values.length) { - return this._values[index]; + if (index < this.values.length) { + return this.values[index]; } else { - p5._friendlyError( + this._friendlyError( 'The index parameter is trying to set a value outside the bounds of the vector', 'p5.Vector.setValue' ); @@ -142,10 +179,10 @@ class Vector { * @throws Will throw an error if the index is outside the bounds of the vector, meaning if you try to set a value at a position that doesn't exist in the vector. */ setValue(index, value) { - if (index < this._values.length) { - this._values[index] = value; + if (index < this.values.length) { + this.values[index] = value; } else { - p5._friendlyError( + this._friendlyError( 'The index parameter is trying to set a value outside the bounds of the vector', 'p5.Vector.setValue' ); @@ -162,7 +199,7 @@ class Vector { * @returns {Number} The y component of the vector. Returns 0 if the value is not defined. */ get y() { - return this._values[1] || 0; + return this.values[1] || 0; } /** @@ -175,7 +212,7 @@ class Vector { * @returns {Number} The z component of the vector. Returns 0 if the value is not defined. */ get z() { - return this._values[2] || 0; + return this.values[2] || 0; } /** @@ -188,7 +225,7 @@ class Vector { * @returns {Number} The w component of the vector. Returns 0 if the value is not defined. */ get w() { - return this._values[3] || 0; + return this.values[3] || 0; } /** @@ -201,8 +238,8 @@ class Vector { * @param {Number} xVal - The new value for the x component. */ set x(xVal) { - if (this._values.length > 1) { - this._values[0] = xVal; + if (this.values.length > 1) { + this.values[0] = xVal; } } @@ -216,8 +253,8 @@ class Vector { * @param {Number} yVal - The new value for the y component. */ set y(yVal) { - if (this._values.length > 1) { - this._values[1] = yVal; + if (this.values.length > 1) { + this.values[1] = yVal; } } @@ -231,8 +268,8 @@ class Vector { * @param {Number} zVal - The new value for the z component. */ set z(zVal) { - if (this._values.length > 2) { - this._values[2] = zVal; + if (this.values.length > 2) { + this.values[2] = zVal; } } @@ -246,8 +283,8 @@ class Vector { * @param {Number} wVal - The new value for the w component. */ set w(wVal) { - if (this._values.length > 3) { - this._values[3] = wVal; + if (this.values.length > 3) { + this.values[3] = wVal; } } @@ -269,7 +306,7 @@ class Vector { * } */ toString() { - return `vector[${this._values.join(', ')}]`; + return `vector[${this.values.join(', ')}]`; } /** @@ -327,13 +364,12 @@ class Vector { */ set(...args) { if (args[0] instanceof Vector) { - this._values = args[0].values.slice(); + this.values = args[0].values.slice(); } else if (Array.isArray(args[0])) { - this._values = args[0].map(arg => arg || 0); + this.values = args[0].map(arg => arg || 0); } else { - this._values = args.map(arg => arg || 0); + this.values = args.map(arg => arg || 0); } - this.dimensions = this._values.length; return this; } @@ -363,9 +399,9 @@ class Vector { */ copy() { if (this.isPInst) { - return new Vector(this._fromRadians, this._toRadians, ...this._values); + return new Vector(this._fromRadians, this._toRadians, ...this.values); } else { - return new Vector(...this._values); + return new Vector(...this.values); } } @@ -376,8 +412,11 @@ class Vector { * another p5.Vector object, as in `v.add(v2)`, or * an array of numbers, as in `v.add([1, 2, 3])`. * - * If a value isn't provided for a component, it won't change. For - * example, `v.add(4, 5)` adds 4 to `v.x`, 5 to `v.y`, and 0 to `v.z`. + * You should add vectors only when they are the same size. When two vectors + * of different sizes are added, the smaller dimension will be used, any + * additional values of the longer vector will be ignored. For example, + * adding `[1, 2, 3]` and `[4, 5]` will result in `[5, 7]`. + * * Calling `add()` with no arguments, as in `v.add()`, has no effect. * * This method supports N-dimensional vectors. @@ -493,31 +532,33 @@ class Vector { * @param {p5.Vector|Number[]} value The vector to add * @chainable */ - add(...args) { - if (args[0] instanceof Vector) { - args = args[0].values; - } else if (Array.isArray(args[0])) { - args = args[0]; + add(args) { + const minDimension = prioritizeSmallerDimension(this.dimensions, args); + shrinkToDimension(this.values, minDimension); + + for (let i = 0; i < this.values.length; i++) { + this.values[i] += args[i]; } - args.forEach((value, index) => { - this._values[index] = (this._values[index] || 0) + (value || 0); - }); + return this; } /** - * Performs modulo (remainder) division with a vector's `x`, `y`, and `z` - * components. + * Performs modulo (remainder) division with a vector's components. * * `rem()` can use separate numbers, as in `v.rem(1, 2, 3)`, * another p5.Vector object, as in `v.rem(v2)`, or * an array of numbers, as in `v.rem([1, 2, 3])`. * * If only one value is provided, as in `v.rem(2)`, then all the components - * will be set to their values modulo 2. If two values are provided, as in - * `v.rem(2, 3)`, then `v.z` won't change. Calling `rem()` with no + * will be set to their values modulo 2. Calling `rem()` with no * arguments, as in `v.rem()`, has no effect. * + * You should modulo vectors only when they are the same size. When two + * vectors of different sizes are used, the smaller dimension will be + * used, any additional values of the longer vector will be ignored. + * For example, taking `[3, 6, 9]` modulo `[2, 4]` will result in `[1, 2]`. + * * The static version of `rem()`, as in `p5.Vector.rem(v2, v1)`, returns a * new p5.Vector object and doesn't change the * originals. @@ -602,7 +643,7 @@ class Vector { * let v2 = createVector(2, 3, 4); * * // Divide without modifying the original vectors. - * let v3 = p5.Vector.rem(v1, v2); + * let v3 = p5.Vector.rem(v1, v2); * * // Prints 'p5.Vector Object : [1, 1, 1]'. * print(v3.toString()); @@ -612,72 +653,40 @@ class Vector { * @param {p5.Vector | Number[]} value divisor vector. * @chainable */ - rem(x, y, z) { - if (x instanceof Vector) { - if ([x.x, x.y, x.z].every(Number.isFinite)) { - const xComponent = parseFloat(x.x); - const yComponent = parseFloat(x.y); - const zComponent = parseFloat(x.z); - return calculateRemainder3D.call( - this, - xComponent, - yComponent, - zComponent - ); - } - } else if (Array.isArray(x)) { - if (x.every(element => Number.isFinite(element))) { - if (x.length === 2) { - return calculateRemainder2D.call(this, x[0], x[1]); - } - if (x.length === 3) { - return calculateRemainder3D.call(this, x[0], x[1], x[2]); - } - } - } else if (arguments.length === 1) { - if (Number.isFinite(arguments[0]) && arguments[0] !== 0) { - this.x = this.x % arguments[0]; - this.y = this.y % arguments[0]; - this.z = this.z % arguments[0]; - return this; - } - } else if (arguments.length === 2) { - const vectorComponents = [...arguments]; - if (vectorComponents.every(element => Number.isFinite(element))) { - if (vectorComponents.length === 2) { - return calculateRemainder2D.call( - this, - vectorComponents[0], - vectorComponents[1] - ); + rem(args) { + const minDimension = prioritizeSmallerDimension(this.dimensions, args); + + shrinkToDimension(this.values, minDimension); + + if(Array.isArray(args)){ + for (let i = 0; i < this.values.length; i++) { + if (args[i] > 0) { + this.values[i] = this.values[i] % args[i]; } } - } else if (arguments.length === 3) { - const vectorComponents = [...arguments]; - if (vectorComponents.every(element => Number.isFinite(element))) { - if (vectorComponents.length === 3) { - return calculateRemainder3D.call( - this, - vectorComponents[0], - vectorComponents[1], - vectorComponents[2] - ); - } + } else if(args > 0) { + for (let i = 0; i < this.values.length; i++) { + this.values[i] = this.values[i] % args; } } + + return this; } /** - * Subtracts from a vector's `x`, `y`, and `z` components. + * Subtracts from a vector's components. * * `sub()` can use separate numbers, as in `v.sub(1, 2, 3)`, another * p5.Vector object, as in `v.sub(v2)`, or an array * of numbers, as in `v.sub([1, 2, 3])`. * - * If a value isn't provided for a component, it won't change. For - * example, `v.sub(4, 5)` subtracts 4 from `v.x`, 5 from `v.y`, and 0 from `v.z`. * Calling `sub()` with no arguments, as in `v.sub()`, has no effect. * + * You should subtract vectors only when they are the same size. When two + * vectors of different sizes are used, the smaller dimension will be + * used, any additional values of the longer vector will be ignored. + * For example, subtracting `[1, 2]` from `[3, 5, 7]` will result in `[2, 3]`. + * * The static version of `sub()`, as in `p5.Vector.sub(v2, v1)`, returns a new * p5.Vector object and doesn't change the * originals. @@ -786,36 +795,33 @@ class Vector { * @param {p5.Vector|Number[]} value the vector to subtract * @chainable */ - sub(...args) { - if (args[0] instanceof Vector) { - args[0].values.forEach((value, index) => { - this._values[index] -= value || 0; - }); - } else if (Array.isArray(args[0])) { - args[0].forEach((value, index) => { - this._values[index] -= value || 0; - }); - } else { - args.forEach((value, index) => { - this._values[index] -= value || 0; - }); + sub(args) { + const minDimension = prioritizeSmallerDimension(this.dimensions, args); + shrinkToDimension(this.values, minDimension); + + for (let i = 0; i < this.values.length; i++) { + this.values[i] -= args[i]; } + return this; } /** - * Multiplies a vector's `x`, `y`, and `z` components. + * Multiplies a vector's components. * * `mult()` can use separate numbers, as in `v.mult(1, 2, 3)`, another * p5.Vector object, as in `v.mult(v2)`, or an array * of numbers, as in `v.mult([1, 2, 3])`. * * If only one value is provided, as in `v.mult(2)`, then all the components - * will be multiplied by 2. If a value isn't provided for a component, it - * won't change. For example, `v.mult(4, 5)` multiplies `v.x` by, `v.y` by 5, - * and `v.z` by 1. Calling `mult()` with no arguments, as in `v.mult()`, has + * will be multiplied by 2. Calling `mult()` with no arguments, as in `v.mult()`, has * no effect. * + * You should multiply vectors only when they are the same size. When two + * vectors of different sizes are multiplied, the smaller dimension will be + * used, any additional values of the longer vector will be ignored. + * For example, multiplying `[1, 2, 3]` by `[4, 5]` will result in `[4, 10]`. + * * The static version of `mult()`, as in `p5.Vector.mult(v, 2)`, returns a new * p5.Vector object and doesn't change the * originals. @@ -977,60 +983,39 @@ class Vector { * @param {p5.Vector} v vector to multiply with the components of the original vector. * @chainable */ - mult(...args) { - if (args.length === 1 && args[0] instanceof Vector) { - const v = args[0]; - const maxLen = Math.min(this._values.length, v.values.length); - for (let i = 0; i < maxLen; i++) { - if (Number.isFinite(v.values[i]) && typeof v.values[i] === 'number') { - this._values[i] *= v.values[i]; - } else { - console.warn( - 'p5.Vector.prototype.mult:', - 'v contains components that are either undefined or not finite numbers' - ); - return this; - } - } - } else if (args.length === 1 && Array.isArray(args[0])) { - const arr = args[0]; - const maxLen = Math.min(this._values.length, arr.length); - for (let i = 0; i < maxLen; i++) { - if (Number.isFinite(arr[i]) && typeof arr[i] === 'number') { - this._values[i] *= arr[i]; - } else { - console.warn( - 'p5.Vector.prototype.mult:', - 'arr contains elements that are either undefined or not finite numbers' - ); - return this; - } + mult(args) { + const minDimension = prioritizeSmallerDimension(this.dimensions, args); + shrinkToDimension(this.values, minDimension); + + if(Array.isArray(args)){ + for (let i = 0; i < this.values.length; i++) { + this.values[i] *= args[i]; } - } else if ( - args.length === 1 && - typeof args[0] === 'number' && - Number.isFinite(args[0]) - ) { - for (let i = 0; i < this._values.length; i++) { - this._values[i] *= args[0]; + } else { + for (let i = 0; i < this.values.length; i++) { + this.values[i] *= args; } } + return this; } /** - * Divides a vector's `x`, `y`, and `z` components. + * Divides a vector's components. * * `div()` can use separate numbers, as in `v.div(1, 2, 3)`, another * p5.Vector object, as in `v.div(v2)`, or an array * of numbers, as in `v.div([1, 2, 3])`. * * If only one value is provided, as in `v.div(2)`, then all the components - * will be divided by 2. If a value isn't provided for a component, it - * won't change. For example, `v.div(4, 5)` divides `v.x` by, `v.y` by 5, - * and `v.z` by 1. Calling `div()` with no arguments, as in `v.div()`, has + * will be divided by 2. Calling `div()` with no arguments, as in `v.div()`, has * no effect. * + * You should divide vectors only when they are the same size. When two + * vectors of different sizes are divided, the smaller dimension will be + * used, any additional values of the longer vector will be ignored. + * For example, dividing `[8, 12, 21]` by `[2, 3]` will result in `[4, 4]`. + * * The static version of `div()`, as in `p5.Vector.div(v, 2)`, returns a new * p5.Vector object and doesn't change the * originals. @@ -1193,57 +1178,41 @@ class Vector { * @param {p5.Vector} v vector to divide the components of the original vector by. * @chainable */ - div(...args) { - if (args.length === 0) return this; - if (args.length === 1 && args[0] instanceof Vector) { - const v = args[0]; - if ( - v._values.every( - val => Number.isFinite(val) && typeof val === 'number' - ) - ) { - if (v._values.some(val => val === 0)) { - console.warn('p5.Vector.prototype.div:', 'divide by 0'); + div(args) { + const minDimension = prioritizeSmallerDimension(this.dimensions, args); + + if (Array.isArray(args)) { + for (let i = 0; i < minDimension; i++) { + if ((typeof args[i] !== 'number' || args[i] === 0)) { + if (!this.friendlyErrorsDisabled()) { + console.warn( + 'p5.Vector.prototype.div', + 'Arguments contain components that are 0' + ); + } return this; } - this._values = this._values.map((val, i) => val / v._values[i]); - } else { - console.warn( - 'p5.Vector.prototype.div:', - 'vector contains components that are either undefined or not finite numbers' - ); } - return this; - } - - if (args.length === 1 && Array.isArray(args[0])) { - const arr = args[0]; - if (arr.every(val => Number.isFinite(val) && typeof val === 'number')) { - if (arr.some(val => val === 0)) { - console.warn('p5.Vector.prototype.div:', 'divide by 0'); - return this; - } - this._values = this._values.map((val, i) => val / arr[i]); - } else { + } else if(typeof args !== 'number' || args === 0) { + if (!this.friendlyErrorsDisabled()) { console.warn( - 'p5.Vector.prototype.div:', - 'array contains components that are either undefined or not finite numbers' + 'p5.Vector.prototype.div', + 'Arguments contain components that are 0' ); } return this; } - if (args.every(val => Number.isFinite(val) && typeof val === 'number')) { - if (args.some(val => val === 0)) { - console.warn('p5.Vector.prototype.div:', 'divide by 0'); - return this; + shrinkToDimension(this.values, minDimension); + + if(Array.isArray(args)){ + for (let i = 0; i < this.values.length; i++) { + this.values[i] /= args[i]; } - this._values = this._values.map((val, i) => val / args[0]); } else { - console.warn( - 'p5.Vector.prototype.div:', - 'arguments contain components that are either undefined or not finite numbers' - ); + for (let i = 0; i < this.values.length; i++) { + this.values[i] /= args; + } } return this; @@ -1281,7 +1250,12 @@ class Vector { * } */ mag() { - return Math.sqrt(this.magSq()); + let sum = 0; + for (let i = 0; i < this.values.length; i++) { + const component = this.values[i]; + sum += component * component; + } + return Math.sqrt(sum); } /** @@ -1297,7 +1271,8 @@ class Vector { * // Create a p5.Vector object. * let p = createVector(30, 40); * - * // Draw a line from the origin. + * // Draw a line from th + * e origin. * line(0, 0, p.x, p.y); * * // Style the text. @@ -1312,10 +1287,12 @@ class Vector { * } */ magSq() { - return this._values.reduce( - (sum, component) => sum + component * component, - 0 - ); + let sum = 0; + for (let i = 0; i < this.values.length; i++) { + const component = this.values[i]; + sum += component * component; + } + return sum; } /** @@ -1415,12 +1392,16 @@ class Vector { * @return {Number} */ dot(...args) { + let vals = args; if (args[0] instanceof Vector) { - return this.dot(...args[0]._values); + vals = args[0].values; } - return this._values.reduce((sum, component, index) => { - return sum + component * (args[index] || 0); - }, 0); + const minDimension = prioritizeSmallerDimension(this.dimensions, vals); + let sum = 0; + for (let i = 0; i < minDimension; i++) { + sum += this.values[i] * vals[i]; + } + return sum; } /** @@ -1430,6 +1411,9 @@ class Vector { * by two vectors. The cross product's magnitude is the area of the parallelogram * formed by the original two vectors. * + * The cross product is defined on 3-dimensional vectors, and will use the `x`, `y`, + * and `z` components. This method should only be used with 3D vectors. + * * The static version of `cross()`, as in `p5.Vector.cross(v1, v2)`, is the same * as calling `v1.cross(v2)`. * @@ -1575,7 +1559,13 @@ class Vector { * } */ dist(v) { - return v.copy().sub(this).mag(); + const minDimension = prioritizeSmallerDimension(this.dimensions, v.values); + let sum = 0; + for (let i = 0; i < minDimension; i++) { + const component = this.values[i] - v.values[i]; + sum += component * component; + } + return Math.sqrt(sum); } /** @@ -2037,8 +2027,18 @@ class Vector { * } */ setHeading(a) { - if (this.isPInst) a = this._toRadians(a); - let m = this.mag(); + if (this.dimensions < 2 || ( + this._values instanceof Array && this._values.slice(2).some(v => v !== 0)) + ) { + p5._friendlyError( + 'p5.Vector.setHeading() only supports 2D vectors (z === 0). ' + + 'For 3D or higher-dimensional vectors, use rotate() or another ' + + 'appropriate method instead.', + 'p5.Vector.setHeading' + ); + return this; + } + const m = this.mag(); this.x = m * Math.cos(a); this.y = m * Math.sin(a); return this; @@ -2737,6 +2737,7 @@ class Vector { * Returns the vector's components as an array of numbers. * * @return {Number[]} array with the vector's components. + * @deprecated To retrieve vector components, use `v.values` * @example * // META:norender * function setup() { @@ -2823,15 +2824,15 @@ class Vector { equals(...args) { let values; if (args[0] instanceof Vector) { - values = args[0]._values; + values = args[0].values; } else if (Array.isArray(args[0])) { values = args[0]; } else { values = args; } - for (let i = 0; i < this._values.length; i++) { - if (this._values[i] !== (values[i] || 0)) { + for (let i = 0; i < this.values.length; i++) { + if (this.values[i] !== (values[i] || 0)) { return false; } } @@ -2851,8 +2852,8 @@ class Vector { * @chainable */ clampToZero() { - for (let i = 0; i < this._values.length; i++) { - this._values[i] = this._clampToZero(this._values[i]); + for (let i = 0; i < this.values.length; i++) { + this.values[i] = this._clampToZero(this.values[i]); } return this; } @@ -3109,7 +3110,7 @@ class Vector { if (!target) { target = v1.copy(); if (arguments.length === 3) { - p5._friendlyError( + this._friendlyError( 'The target parameter is undefined, it should be of type p5.Vector', 'p5.Vector.add' ); @@ -3156,7 +3157,7 @@ class Vector { if (!target) { target = v1.copy(); if (arguments.length === 3) { - p5._friendlyError( + this._friendlyError( 'The target parameter is undefined, it should be of type p5.Vector', 'p5.Vector.sub' ); @@ -3200,7 +3201,7 @@ class Vector { if (!target) { target = v.copy(); if (arguments.length === 3) { - p5._friendlyError( + this._friendlyError( 'The target parameter is undefined, it should be of type p5.Vector', 'p5.Vector.mult' ); @@ -3226,7 +3227,7 @@ class Vector { target = v.copy(); } else { if (!(target instanceof Vector)) { - p5._friendlyError( + this._friendlyError( 'The target parameter should be of type p5.Vector', 'p5.Vector.rotate' ); @@ -3270,7 +3271,7 @@ class Vector { target = v.copy(); if (arguments.length === 3) { - p5._friendlyError( + this._friendlyError( 'The target parameter is undefined, it should be of type p5.Vector', 'p5.Vector.div' ); @@ -3338,7 +3339,7 @@ class Vector { if (!target) { target = v1.copy(); if (arguments.length === 4) { - p5._friendlyError( + this._friendlyError( 'The target parameter is undefined, it should be of type p5.Vector', 'p5.Vector.lerp' ); @@ -3368,7 +3369,7 @@ class Vector { if (!target) { target = v1.copy(); if (arguments.length === 4) { - p5._friendlyError( + this._friendlyError( 'The target parameter is undefined, it should be of type p5.Vector', 'p5.Vector.slerp' ); @@ -3422,7 +3423,7 @@ class Vector { target = v.copy(); } else { if (!(target instanceof Vector)) { - p5._friendlyError( + this._friendlyError( 'The target parameter should be of type p5.Vector', 'p5.Vector.normalize' ); @@ -3448,7 +3449,7 @@ class Vector { target = v.copy(); } else { if (!(target instanceof Vector)) { - p5._friendlyError( + this._friendlyError( 'The target parameter should be of type p5.Vector', 'p5.Vector.limit' ); @@ -3474,7 +3475,7 @@ class Vector { target = v.copy(); } else { if (!(target instanceof Vector)) { - p5._friendlyError( + this._friendlyError( 'The target parameter should be of type p5.Vector', 'p5.Vector.setMag' ); @@ -3530,7 +3531,7 @@ class Vector { target = incidentVector.copy(); } else { if (!(target instanceof Vector)) { - p5._friendlyError( + this._friendlyError( 'The target parameter should be of type p5.Vector', 'p5.Vector.reflect' ); @@ -3571,7 +3572,7 @@ class Vector { } else if (v1 instanceof Array) { v = new Vector().set(v1); } else { - p5._friendlyError( + this._friendlyError( 'The v1 parameter should be of type Array or p5.Vector', 'p5.Vector.equals' ); @@ -3659,6 +3660,11 @@ function vector(p5, fn) { */ p5.Vector = Vector; + Vector.prototype._friendlyError = p5._friendlyError; + Vector.prototype.friendlyErrorsDisabled = function() { + return p5.disableFriendlyErrors; + }; + /** * The x component of the vector * @type {Number} diff --git a/src/math/patch-vector.js b/src/math/patch-vector.js new file mode 100644 index 0000000000..d7f683c299 --- /dev/null +++ b/src/math/patch-vector.js @@ -0,0 +1,92 @@ +import { Vector } from './p5.Vector.js'; + +/** + * @private + * @internal + */ +export function _defaultEmptyVector(target){ + return function(...args){ + if(args.length === 0){ + this._friendlyError( + 'In 1.x, createVector() was a shortcut for createVector(0, 0, 0). In 2.x, p5.js has vectors of any dimension, so you must provide your desired number of zeros. Use createVector(0, 0) for a 2D vector and createVector(0, 0, 0) for a 3D vector.', + 'p5.createVector' + ); + return target.call(this, 0, 0, 0); + }else{ + if (Array.isArray(args[0])) { + args = args[0]; + } + return target.call(this, ...args); + } + }; +} + + +/** + * @private + * @internal + */ +export function _validatedVectorOperation(expectsSoloNumberArgument){ + return function(target){ + return function (...args) { + if (args.length === 0) { + // No arguments? No action + return this; + } else if (args[0] instanceof Vector) { + // First argument is a vector? Make it an array + args = args[0].values; + } else if (Array.isArray(args[0])) { + // First argument is an array? Great, keep it! + args = args[0]; + } else if (args.length === 1){ + // Special case for a solo numeric arguments only applies sometimes + if (expectsSoloNumberArgument) { + args = args[0]; + } + } + + if(Array.isArray(args)){ + for (let i = 0; i < args.length; i++) { + const v = args[i]; + if (typeof v !== 'number' || !Number.isFinite(v)) { + if (!Vector.friendlyErrorsDisabled()) { + this._friendlyError( + 'Arguments contain non-finite numbers', + 'p5.Vector' + ); + } + return this; + } + } + } else { + if (typeof args !== 'number' || !Number.isFinite(args)) { + if (!Vector.friendlyErrorsDisabled()) { + this._friendlyError( + 'Arguments contain non-finite numbers', + 'p5.Vector' + ); + } + return this; + } + } + + return target.call(this, args); + }; + }; +} + +/** + * Each of the following decorators validates the data on vector operations. + * These ensure that the arguments are consistently formatted, and that + * pre-conditions are met. + */ +export default function vectorValidation(p5, fn, lifecycles){ + + p5.registerDecorator('p5.prototype.createVector', _defaultEmptyVector); + p5.registerDecorator('p5.Vector.prototype.mult', _validatedVectorOperation(true)); + p5.registerDecorator('p5.Vector.prototype.rem', _validatedVectorOperation(true)); + p5.registerDecorator('p5.Vector.prototype.div', _validatedVectorOperation(true)); + p5.registerDecorator('p5.Vector.prototype.add', _validatedVectorOperation(false)); + p5.registerDecorator('p5.Vector.prototype.sub', _validatedVectorOperation(false)); + +} diff --git a/src/math/random.js b/src/math/random.js index 984094fc3d..717c6bccc1 100644 --- a/src/math/random.js +++ b/src/math/random.js @@ -2,7 +2,6 @@ * @module Math * @submodule Random * @for p5 - * @requires core */ function random(p5, fn){ @@ -106,12 +105,7 @@ function random(p5, fn){ * For example, calling `random(-5, 10.2)` returns values from -5 up to but * not including 10.2. * - * @method random - * @param {Number} [min] lower bound (inclusive). - * @param {Number} [max] upper bound (exclusive). - * @return {Number} random number. - * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -127,8 +121,9 @@ function random(p5, fn){ * * describe('A black dot appears in a random position on a gray square.'); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -144,8 +139,9 @@ function random(p5, fn){ * * describe('A black dot appears in a random position on a gray square.'); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -166,8 +162,9 @@ function random(p5, fn){ * * describe('An animal face is displayed at random. Either a lion, tiger, or bear.'); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -188,8 +185,9 @@ function random(p5, fn){ * strokeWeight(5); * point(x, y); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -210,8 +208,9 @@ function random(p5, fn){ * strokeWeight(5); * point(x, y); * } + * ``` * - * @example + * ```js example * let x = 50; * let y = 50; * @@ -231,6 +230,41 @@ function random(p5, fn){ * // Draw the point. * point(x, y); * } + * ``` + * + * `random()` can also be used in shaders with p5.strands. The following example + * uses `random()` to create varying colors on a shape. + * + * ```js example + * let myShader; + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * myShader = buildColorShader(shaderCallback); + * describe('A sphere with randomly varying colors.'); + * } + * + * function shaderCallback() { + * let r = random(); + * let g = random(); + * let b = random(); + * finalColor.begin(); + * finalColor.set([r, g, b, 1]); + * finalColor.end(); + * } + * + * function draw() { + * background(220); + * shader(myShader); + * noStroke(); + * sphere(30); + * } + * ``` + * + * @method random + * @param {Number} [min] lower bound (inclusive). + * @param {Number} [max] upper bound (exclusive). + * @return {Number} random number. */ /** * @method random diff --git a/src/math/trigonometry.js b/src/math/trigonometry.js index 979adb1c16..0ca07bf84a 100644 --- a/src/math/trigonometry.js +++ b/src/math/trigonometry.js @@ -2,8 +2,6 @@ * @module Math * @submodule Trigonometry * @for p5 - * @requires core - * @requires constants */ import * as constants from '../core/constants'; @@ -774,7 +772,8 @@ function trigonometry(p5, fn){ * @returns {Number} */ fn._toRadians = function(angle) { - if (this._angleMode === DEGREES) { + // returns undefined if no argument + if (typeof angle !== 'undefined' && this._angleMode === DEGREES) { return angle * constants.DEG_TO_RAD; } return angle; diff --git a/src/shape/2d_primitives.js b/src/shape/2d_primitives.js index aa246664b8..4fab185f69 100644 --- a/src/shape/2d_primitives.js +++ b/src/shape/2d_primitives.js @@ -2,8 +2,6 @@ * @module Shape * @submodule 2D Primitives * @for p5 - * @requires core - * @requires constants */ import * as constants from '../core/constants'; @@ -1079,7 +1077,6 @@ function primitives(p5, fn){ * rect(-20, -30, 55, 55); * } */ - /** * @method rect * @param {Number} x diff --git a/src/shape/attributes.js b/src/shape/attributes.js index f6bcf02576..41fe554849 100644 --- a/src/shape/attributes.js +++ b/src/shape/attributes.js @@ -2,8 +2,6 @@ * @module Shape * @submodule Attributes * @for p5 - * @requires core - * @requires constants */ import * as constants from '../core/constants'; @@ -35,6 +33,8 @@ function attributes(p5, fn){ * the constants `CENTER`, `RADIUS`, `CORNER`, and `CORNERS` are defined this * way. JavaScript is a case-sensitive language. * + * Calling `ellipseMode()` without an argument returns the current ellipseMode, either `CENTER`, `RADIUS`, `CORNER`, or `CORNERS`. + * * @method ellipseMode * @param {(CENTER|RADIUS|CORNER|CORNERS)} mode either CENTER, RADIUS, CORNER, or CORNERS * @chainable @@ -77,8 +77,15 @@ function attributes(p5, fn){ * describe('A white circle with a gray circle at its top-left corner. Both circles have black outlines.'); * } */ + /** + * @method ellipseMode + * @return {(CENTER|RADIUS|CORNER|CORNERS)} the current ellipseMode. + */ fn.ellipseMode = function(m) { // p5._validateParameters('ellipseMode', arguments); + if (typeof m === 'undefined') { // getter + return this._renderer?.states.ellipseMode; + } if ( m === constants.CORNER || m === constants.CORNERS || @@ -100,8 +107,10 @@ function attributes(p5, fn){ * In WebGL mode, `noSmooth()` causes all shapes to be drawn with jagged * (aliased) edges. The functions don't affect images or fonts. * + * Note: In WebGPU mode, you must `await` this function. + * * @method noSmooth - * @chainable + * @return {void|Promise} * * @example * let heart; @@ -155,10 +164,10 @@ function attributes(p5, fn){ if ('imageSmoothingEnabled' in this.drawingContext) { this.drawingContext.imageSmoothingEnabled = false; } + return this; } else { - this.setAttributes('antialias', false); + return this.setAttributes('antialias', false); } - return this; }; /** @@ -186,6 +195,8 @@ function attributes(p5, fn){ * constants `CENTER`, `RADIUS`, `CORNER`, and `CORNERS` are defined this way. * JavaScript is a case-sensitive language. * + * Calling `rectMode()` without an argument returns the current rectMode, either `CORNER`, `CORNERS`, `CENTER`, or `RADIUS`. + * * @method rectMode * @param {(CENTER|RADIUS|CORNER|CORNERS)} mode either CORNER, CORNERS, CENTER, or RADIUS * @chainable @@ -254,8 +265,15 @@ function attributes(p5, fn){ * describe('A small gray square drawn at the center of a white square.'); * } */ + /** + * @method rectMode + * @return {(CENTER|RADIUS|CORNER|CORNERS)} the current rectMode. + */ fn.rectMode = function(m) { // p5._validateParameters('rectMode', arguments); + if (typeof m === 'undefined') { // getter + return this._renderer?.states.rectMode; + } if ( m === constants.CORNER || m === constants.CORNERS || @@ -380,6 +398,9 @@ function attributes(p5, fn){ */ fn.strokeCap = function(cap) { // p5._validateParameters('strokeCap', arguments); + if (typeof cap === 'undefined') { // getter + return this._renderer.strokeCap(); + } if ( cap === constants.ROUND || cap === constants.SQUARE || @@ -401,6 +422,8 @@ function attributes(p5, fn){ * the constants `MITER`, `BEVEL`, and `ROUND` are defined this way. * JavaScript is a case-sensitive language. * + * Calling `strokeJoin()` without an argument returns the current stroke join style, either `MITER`, `BEVEL`, or `ROUND`. + * * @method strokeJoin * @param {(MITER|BEVEL|ROUND)} join either MITER, BEVEL, or ROUND * @chainable @@ -467,8 +490,15 @@ function attributes(p5, fn){ * describe('A right-facing arrowhead shape with a rounded tip in center of canvas.'); * } */ + /** + * @method strokeJoin + * @return {(MITER|BEVEL|ROUND)} the current stroke join style. + */ fn.strokeJoin = function(join) { // p5._validateParameters('strokeJoin', arguments); + if (typeof join === 'undefined') { // getter + return this._renderer.strokeJoin(); + } if ( join === constants.ROUND || join === constants.BEVEL || @@ -485,6 +515,8 @@ function attributes(p5, fn){ * * Note: `strokeWeight()` is affected by transformations, especially calls to * scale(). + * + * Calling `strokeWeight()` without an argument returns the current stroke weight as a number. * * @method strokeWeight * @param {Number} weight the weight of the stroke (in pixels). @@ -527,10 +559,13 @@ function attributes(p5, fn){ * describe('Two horizontal black lines. The top line is thin and the bottom is five times thicker than the top.'); * } */ + /** + * @method strokeWeight + * @return {Number} the current stroke weight. + */ fn.strokeWeight = function(w) { // p5._validateParameters('strokeWeight', arguments); - this._renderer.strokeWeight(w); - return this; + return this._renderer.strokeWeight(w); }; } diff --git a/src/shape/curves.js b/src/shape/curves.js index 5f5e278811..7e2b49a2a1 100644 --- a/src/shape/curves.js +++ b/src/shape/curves.js @@ -2,7 +2,6 @@ * @module Shape * @submodule Curves * @for p5 - * @requires core */ function curves(p5, fn){ diff --git a/src/shape/custom_shapes.js b/src/shape/custom_shapes.js index 3a09200f75..c34ec0f5a4 100644 --- a/src/shape/custom_shapes.js +++ b/src/shape/custom_shapes.js @@ -2,8 +2,6 @@ * @module Shape * @submodule Custom Shapes * @for p5 - * @requires core - * @requires constants */ // REMINDER: remove .js extension (currently using it to run file locally) @@ -466,6 +464,85 @@ class Quad extends ShapePrimitive { } } +/* + * TODO: Future enhancement β€” align with arcVertex proposal (#6459) + * Currently stores start/stop angles and mode (OPEN/CHORD/PIE). + * For full SVG compatibility and arcs inside beginShape/endShape, + * we may want to add an arc-to-vertex variant that matches the + * arcVertex() API discussed in #6459. + */ + +class ArcPrimitive extends ShapePrimitive { + #x; + #y; + #w; + #h; + #start; + #stop; + #mode; + #vertexCapacity = 2; + + constructor(startVertex, endVertex, x, y, w, h, start, stop, mode) { + // ShapePrimitive requires at least one vertex; pass a placeholder + super(startVertex, endVertex); + this.#x = x; + this.#y = y; + this.#w = w; + this.#h = h; + this.#start = start; + this.#stop = stop; + this.#mode = mode; + } + + get x() { return this.#x; } + get y() { return this.#y; } + get w() { return this.#w; } + get h() { return this.#h; } + get start() { return this.#start; } + get stop() { return this.#stop; } + get mode() { return this.#mode; } + get startVertex() { return this.vertices[0]; } + get endVertex() { return this.vertices[1]; } + + get vertexCapacity() { + return this.#vertexCapacity; + } + + accept(visitor) { + visitor.visitArcPrimitive(this); + } +} + +class EllipsePrimitive extends ShapePrimitive { + #x; + #y; + #w; + #h; + #vertexCapacity = 1; + + constructor(centerVertex, x, y, w, h) { + + super(centerVertex); + this.#x = x; + this.#y = y; + this.#w = w; + this.#h = h; + } + + get x() { return this.#x; } + get y() { return this.#y; } + get w() { return this.#w; } + get h() { return this.#h; } + + get vertexCapacity() { + return this.#vertexCapacity; + } + + accept(visitor) { + visitor.visitEllipsePrimitive(this); + } +} + // ---- TESSELLATION PRIMITIVES ---- class TriangleFan extends ShapePrimitive { @@ -905,6 +982,49 @@ class Shape { this.#generalVertex('arcVertex', position, textureCoordinates); } + + arcPrimitive(x,y,w,h,start,stop,mode){ + this.beginShape(); + const centerX = x+w/2; + const centerY = y+h/2; + const radiusX = w / 2; + const radiusY = h / 2; + + const startVertex = this.#createVertex( + new Vector( + centerX + radiusX * Math.cos(start), + centerY + radiusY * Math.sin(start) + ) + ); + + const endVertex = this.#createVertex( + new Vector( + centerX + radiusX * Math.cos(stop), + centerY + radiusY * Math.sin(stop) + ) + ); + + const primitive = new ArcPrimitive( + startVertex, + endVertex, + x, y, w, h, + start, + stop, + mode + ); + primitive.addToShape(this); + this.endShape(); + return this; + + } + + ellipsePrimitive(x,y,w,h){ + const centerVertex = this.#createVertex(new Vector(x+w/2,y+h/2)); + + const primitive = new EllipsePrimitive(centerVertex, x, y, w, h); + return primitive.addToShape(this); + } + beginContour(shapeKind = constants.PATH) { if (this.at(-1)?.kind === constants.EMPTY_PATH) { this.contours.pop(); @@ -1003,6 +1123,12 @@ class PrimitiveVisitor { visitArcSegment(arcSegment) { throw new Error('Method visitArcSegment() has not been implemented.'); } + visitArcPrimitive(arc) { + throw new Error('Method visitArcPrimitive() has not been implemented.'); + } + visitEllipsePrimitive(ellipse) { + throw new Error('Method visitEllipsePrimitive() has not been implemented.'); + } // isolated primitives visitPoint(point) { @@ -1033,6 +1159,8 @@ class PrimitiveVisitor { // requires testing class PrimitiveToPath2DConverter extends PrimitiveVisitor { path = new Path2D(); + strokePath = null; + fillPath = null; strokeWeight; constructor({ strokeWeight }) { @@ -1151,6 +1279,59 @@ class PrimitiveToPath2DConverter extends PrimitiveVisitor { this.path.closePath(); } } + visitArcPrimitive(arc) { + const centerX = arc.x + arc.w / 2; + const centerY = arc.y + arc.h / 2; + const radiusX = arc.w / 2; + const radiusY = arc.h / 2; + const startX = centerX + radiusX * Math.cos(arc.start); + const startY = centerY + radiusY * Math.sin(arc.start); + + const delta = arc.stop - arc.start; + const isFullCircle = Math.abs(delta % (2 * Math.PI)) < 0.00001 && + Math.abs(delta) > 0.00001; + + const createPieSlice = ! ( + arc.mode === constants.CHORD || + arc.mode === constants.OPEN || + isFullCircle + ); + + if (!this.fillPath) this.fillPath = new Path2D(this.path); + if (!this.strokePath) this.strokePath = new Path2D(this.path); + + this.fillPath.moveTo(startX, startY); + this.fillPath.ellipse(centerX, centerY, radiusX, radiusY, + 0, arc.start, arc.stop); + if (createPieSlice) { + this.fillPath.lineTo(centerX, centerY); + } + this.fillPath.closePath(); + + this.strokePath.moveTo(startX, startY); + this.strokePath.ellipse(centerX, centerY, radiusX, radiusY, + 0, arc.start, arc.stop); + if (arc.mode === constants.PIE && createPieSlice) { + this.strokePath.lineTo(centerX, centerY); + } + if (arc.mode === constants.PIE || arc.mode === constants.CHORD) { + this.strokePath.closePath(); + } + + // Still maintain base path just in case + this.path.moveTo(startX, startY); + this.path.ellipse(centerX, centerY, radiusX, radiusY, + 0, arc.start, arc.stop); + } + visitEllipsePrimitive(ellipse) { + const centerX = ellipse.x + ellipse.w / 2; + const centerY = ellipse.y + ellipse.h / 2; + const radiusX = ellipse.w / 2; + const radiusY = ellipse.h / 2; + + this.path.moveTo(centerX + radiusX, centerY); + this.path.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI); + } visitQuadStrip(quadStrip) { for (let i = 0; i < quadStrip.vertices.length - 3; i += 2) { const v0 = quadStrip.vertices[i]; @@ -1277,6 +1458,78 @@ class PrimitiveToVerticesConverter extends PrimitiveVisitor { // WebGL itself interprets the vertices as a strip, no reformatting needed this.contours.push(quadStrip.vertices.slice()); } + visitArcPrimitive(arc) { + const startVertex = arc.startVertex; + const endVertex = arc.endVertex; + const centerX = arc.x + arc.w / 2; + const centerY = arc.y + arc.h / 2; + const radiusX = arc.w / 2; + const radiusY = arc.h / 2; + const avgRadius = (radiusX + radiusY) / 2; + + const arcLength = avgRadius * Math.abs(arc.stop - arc.start); + + const numPoints = Math.max(3, Math.ceil(this.curveDetail * arcLength)); + const verts = []; + const interpolateVertexProps = (v1, v2, t) => { + const props = {}; + for (const [key, value] of Object.entries(v1)) { + if (key === 'position') continue; + if (typeof value === 'number' && typeof v2[key] === 'number') { + props[key] = value * (1 - t) + v2[key] * t; + } else { + props[key] = value; + } + } + return props; + }; + if (arc.mode === constants.PIE) { + const centerProps = interpolateVertexProps(startVertex, endVertex, 0.5); + centerProps.position = new Vector(centerX, centerY); + verts.push(new Vertex(centerProps)); + } + + for (let i = 0; i <= numPoints; i++) { + const t = i / numPoints; + const angle = arc.start + (arc.stop - arc.start) * t; + const vertexProps = interpolateVertexProps(startVertex, endVertex, t); + + vertexProps.position = new Vector( + centerX + radiusX * Math.cos(angle), + centerY + radiusY * Math.sin(angle) + ); + + verts.push(new Vertex(vertexProps)); + } + + this.contours.push(verts); + } + visitEllipsePrimitive(ellipse) { + const centerX = ellipse.x + ellipse.w / 2; + const centerY = ellipse.y + ellipse.h / 2; + const radiusX = ellipse.w / 2; + const radiusY = ellipse.h / 2; + const avgRadius = (radiusX + radiusY) / 2; + const perimeter = 2 * Math.PI * avgRadius; + const numPoints = Math.max(3, Math.ceil(this.curveDetail * perimeter)); + const verts = []; + const centerVertex = ellipse.vertices[0]; + for (let i = 0; i <= numPoints; i++) { + const angle = (2 * Math.PI * i) / numPoints; + const vertexProps = {}; + for (const [key, value] of Object.entries(centerVertex)) { + if (key === 'position') continue; + vertexProps[key] = value; + } + vertexProps.position = new Vector( + centerX + radiusX * Math.cos(angle), + centerY + radiusY * Math.sin(angle) + ); + verts.push(new Vertex(vertexProps)); + } + + this.contours.push(verts); + } } class PointAtLengthGetter extends PrimitiveVisitor { @@ -1611,6 +1864,8 @@ function customShapes(p5, fn) { * Note: `bezierVertex()` won’t work when an argument is passed to * beginShape(). * + * Calling `bezierOrder()` without an argument returns the current BΓ©zier order. + * * @method bezierOrder * @param {Number} order The new order to set. Can be either 2 or 3, by default 3 * @@ -2793,6 +3048,8 @@ export { Line, Triangle, Quad, + ArcPrimitive, + EllipsePrimitive, TriangleFan, TriangleStrip, QuadStrip, diff --git a/src/shape/vertex.js b/src/shape/vertex.js index fbe46e0cba..0f41a6c043 100644 --- a/src/shape/vertex.js +++ b/src/shape/vertex.js @@ -2,8 +2,6 @@ * @module Shape * @submodule Custom Shapes * @for p5 - * @requires core - * @requires constants */ function vertex(p5, fn){ diff --git a/src/strands/ir_builders.js b/src/strands/ir_builders.js index efe3908d75..465572bebb 100644 --- a/src/strands/ir_builders.js +++ b/src/strands/ir_builders.js @@ -621,3 +621,185 @@ export function swizzleTrap(id, dimension, strandsContext, onRebind) { }; return trap; } + +export function arrayAccessNode(strandsContext, bufferNode, indexNode, accessMode) { + const { dag, cfg } = strandsContext; + + // Ensure index is a StrandsNode + let index; + if (indexNode instanceof StrandsNode) { + index = indexNode; + } else { + const { id, dimension } = primitiveConstructorNode( + strandsContext, + { baseType: BaseType.INT, dimension: 1 }, + indexNode + ); + index = createStrandsNode(id, dimension, strandsContext); + } + + // Array access returns a single float + const nodeData = DAG.createNodeData({ + nodeType: NodeType.OPERATION, + opCode: OpCode.Binary.ARRAY_ACCESS, + dependsOn: [bufferNode.id, index.id], + dimension: 1, + baseType: BaseType.FLOAT, + accessMode // 'read' or 'read_write' + }); + + const id = DAG.getOrCreateNode(dag, nodeData); + CFG.recordInBasicBlock(cfg, cfg.currentBlock, id); + + return { id, dimension: 1 }; +} + +export function createStructArrayElementProxy(strandsContext, bufferNode, indexNode, schema) { + const { dag, cfg } = strandsContext; + + // Ensure index is a StrandsNode + let index; + if (indexNode instanceof StrandsNode) { + index = indexNode; + } else { + const { id, dimension } = primitiveConstructorNode( + strandsContext, + { baseType: BaseType.INT, dimension: 1 }, + indexNode + ); + index = createStrandsNode(id, dimension, strandsContext); + } + + // Create a plain object with getters/setters for each struct field. + // When read, a field creates an ARRAY_ACCESS IR node with the field name encoded + // in the identifier slot. When written, an ASSIGNMENT IR node is recorded in the CFG. + const proxy = {}; + + for (const field of schema.fields) { + Object.defineProperty(proxy, field.name, { + get() { + // Encode field name in identifier so WGSL backend can emit buf[idx].field + const nodeData = DAG.createNodeData({ + nodeType: NodeType.OPERATION, + opCode: OpCode.Binary.ARRAY_ACCESS, + dependsOn: [bufferNode.id, index.id], + dimension: field.dim, + baseType: BaseType.FLOAT, + identifier: field.name, + }); + const id = DAG.getOrCreateNode(dag, nodeData); + CFG.recordInBasicBlock(cfg, cfg.currentBlock, id); + // When a swizzle assignment fires (e.g. buf[i].vel.y *= -1), onRebind + // receives the new vector ID and writes it back to the buffer field, + // equivalent to buf[i].vel = newVec. + const onRebind = (newFieldID) => { + const accessData = DAG.createNodeData({ + nodeType: NodeType.OPERATION, + opCode: OpCode.Binary.ARRAY_ACCESS, + dependsOn: [bufferNode.id, index.id], + dimension: field.dim, + baseType: BaseType.FLOAT, + identifier: field.name, + }); + const accessID = DAG.getOrCreateNode(dag, accessData); + const assignData = DAG.createNodeData({ + nodeType: NodeType.ASSIGNMENT, + dependsOn: [accessID, newFieldID], + phiBlocks: [], + }); + const assignID = DAG.getOrCreateNode(dag, assignData); + CFG.recordInBasicBlock(cfg, cfg.currentBlock, assignID); + }; + return createStrandsNode(id, field.dim, strandsContext, onRebind); + }, + set(val) { + // Create access node as assignment target (field name in identifier) + const accessData = DAG.createNodeData({ + nodeType: NodeType.OPERATION, + opCode: OpCode.Binary.ARRAY_ACCESS, + dependsOn: [bufferNode.id, index.id], + dimension: field.dim, + baseType: BaseType.FLOAT, + identifier: field.name, + }); + const accessID = DAG.getOrCreateNode(dag, accessData); + + let valueID; + if (val?.isStrandsNode) { + valueID = val.id; + } else { + const { id } = primitiveConstructorNode( + strandsContext, + { baseType: BaseType.FLOAT, dimension: field.dim }, + val + ); + valueID = id; + } + + const assignData = DAG.createNodeData({ + nodeType: NodeType.ASSIGNMENT, + dependsOn: [accessID, valueID], + phiBlocks: [], + }); + const assignID = DAG.getOrCreateNode(dag, assignData); + CFG.recordInBasicBlock(cfg, cfg.currentBlock, assignID); + }, + configurable: true, + }); + } + + return proxy; +} + +export function arrayAssignmentNode(strandsContext, bufferNode, indexNode, valueNode) { + const { dag, cfg } = strandsContext; + + // Ensure index is a StrandsNode + let index; + if (indexNode instanceof StrandsNode) { + index = indexNode; + } else { + const { id, dimension } = primitiveConstructorNode( + strandsContext, + { baseType: BaseType.INT, dimension: 1 }, + indexNode + ); + index = createStrandsNode(id, dimension, strandsContext); + } + + // Ensure value is a StrandsNode + let value; + if (valueNode instanceof StrandsNode) { + value = valueNode; + } else { + const { id, dimension } = primitiveConstructorNode( + strandsContext, + { baseType: BaseType.FLOAT, dimension: 1 }, + valueNode + ); + value = createStrandsNode(id, dimension, strandsContext); + } + + // Create array access node as the assignment target + const arrayAccessData = DAG.createNodeData({ + nodeType: NodeType.OPERATION, + opCode: OpCode.Binary.ARRAY_ACCESS, + dependsOn: [bufferNode.id, index.id], + dimension: 1, + baseType: BaseType.FLOAT + }); + const arrayAccessID = DAG.getOrCreateNode(dag, arrayAccessData); + + // Create assignment node: buffer[index] = value + const assignmentData = DAG.createNodeData({ + nodeType: NodeType.ASSIGNMENT, + dependsOn: [arrayAccessID, value.id], + phiBlocks: [] + }); + const assignmentID = DAG.getOrCreateNode(dag, assignmentData); + + // CRITICAL: Record in CFG to preserve sequential ordering + CFG.recordInBasicBlock(cfg, cfg.currentBlock, assignmentID); + + return { id: assignmentID }; +} diff --git a/src/strands/ir_types.js b/src/strands/ir_types.js index 9f480d5c9d..20a432ffdc 100644 --- a/src/strands/ir_types.js +++ b/src/strands/ir_types.js @@ -11,6 +11,8 @@ export const NodeType = { STATEMENT: 'statement', ASSIGNMENT: 'assignment', }; +export const INSTANCE_ID_VARYING_NAME = '_p5_instanceID'; +export const HOOK_PARAM_PREFIX = '_p5_param_'; export const NodeTypeToName = Object.fromEntries( Object.entries(NodeType).map(([key, val]) => [val, key]) ); @@ -120,6 +122,7 @@ export const OpCode = { LOGICAL_AND: 11, LOGICAL_OR: 12, MEMBER_ACCESS: 13, + ARRAY_ACCESS: 14, }, Unary: { LOGICAL_NOT: 100, @@ -130,6 +133,8 @@ export const OpCode = { Nary: { FUNCTION_CALL: 200, CONSTRUCTOR: 201, + TERNARY: 202, + ARRAY_ASSIGNMENT: 203, }, ControlFlow: { RETURN: 300, diff --git a/src/strands/p5.strands.js b/src/strands/p5.strands.js index 9b58389174..e9d4cd7143 100644 --- a/src/strands/p5.strands.js +++ b/src/strands/p5.strands.js @@ -2,7 +2,6 @@ * @module 3D * @submodule p5.strands * @for p5 - * @requires core */ import { transpileStrandsToJS } from "./strands_transpiler"; @@ -40,6 +39,7 @@ function strands(p5, fn) { ctx.uniforms = []; ctx.vertexDeclarations = new Set(); ctx.fragmentDeclarations = new Set(); + ctx.computeDeclarations = new Set(); ctx.hooks = []; ctx.backend = backend; ctx.active = active; @@ -49,6 +49,7 @@ function strands(p5, fn) { ctx.windowOverrides = {}; ctx.fnOverrides = {}; ctx.graphicsOverrides = {}; + ctx._randomSeed = null; if (active) { p5.disableFriendlyErrors = true; } @@ -61,8 +62,10 @@ function strands(p5, fn) { ctx.uniforms = []; ctx.vertexDeclarations = new Set(); ctx.fragmentDeclarations = new Set(); + ctx.computeDeclarations = new Set(); ctx.hooks = []; ctx.active = false; + ctx._randomSeed = null; p5.disableFriendlyErrors = ctx.previousFES; for (const key in ctx.windowOverrides) { window[key] = ctx.windowOverrides[key]; @@ -113,7 +116,10 @@ function strands(p5, fn) { ////////////////////////////////////////////// const oldModify = p5.Shader.prototype.modify; - p5.Shader.prototype.modify = function (shaderModifier, scope = {}) { + p5.Shader.prototype.modify = function (shaderModifier, scope = {}, options = {}) { + const fnOverrides = {}; + const windowOverrides = {}; + const graphicsOverrides = {}; try { if ( shaderModifier instanceof Function || @@ -128,7 +134,8 @@ function strands(p5, fn) { }); createShaderHooksFunctions(strandsContext, fn, this); // TODO: expose this, is internal for debugging for now. - const options = { parser: true, srcLocations: false }; + options.parser = true; + options.srcLocations = false; // 1. Transpile from strands DSL to JS let strandsCallback; @@ -155,11 +162,24 @@ function strands(p5, fn) { BlockType.GLOBAL, ); pushBlock(strandsContext.cfg, globalScope); + if (options.hook) { + strandsContext.renderer._pInst[options.hook].begin(); + for (const key of strandsContext.renderer._pInst[options.hook]._properties) { + const hookProp = strandsContext.renderer._pInst[options.hook][key]; + fnOverrides[key] = fn[key]; + fn[key] = hookProp; + windowOverrides[key] = window[key]; + window[key] = hookProp; + graphicsOverrides[key] = p5.Graphics.prototype[key]; + p5.Graphics.prototype[key] = hookProp; + } + } if (strandsContext.renderer?._pInst?._runStrandsInGlobalMode) { withTempGlobalMode(strandsContext.renderer._pInst, strandsCallback); } else { strandsCallback(); } + if (options.hook) strandsContext.renderer._pInst[options.hook].end(); popBlock(strandsContext.cfg); // 3. Generate shader code hooks object from the IR @@ -172,6 +192,15 @@ function strands(p5, fn) { return oldModify.call(this, shaderModifier); } } finally { + for (const key in fnOverrides) { + fn[key] = fnOverrides[key]; + } + for (const key in windowOverrides) { + window[key] = windowOverrides[key]; + } + for (const key in graphicsOverrides) { + p5.Graphics[key] = graphicsOverrides[key]; + } // Reset the strands runtime context deinitStrandsContext(strandsContext); } @@ -186,7 +215,18 @@ if (typeof p5 !== "undefined") { /* ------------------------------------------------------------- */ /** - * @property {Object} worldInputs + * @typedef {Object} WorldInputsHook + * @property {any} position + * @property {any} normal + * @property {any} texCoord + * @property {any} color + * @property {function(): undefined} begin + * @property {function(): undefined} end + */ + +/** + * @property {WorldInputsHook} worldInputs + * @beta * @description * A shader hook block that modifies the world-space properties of each vertex in a shader. This hook can be used inside `buildColorShader()` and similar shader `modify()` calls to customize vertex positions, normals, texture coordinates, and colors before rendering. Modifications happen between the `.begin()` and `.end()` methods of the hook. "World space" refers to the coordinate system of the 3D scene, before any camera or projection transformations are applied. * @@ -230,7 +270,23 @@ if (typeof p5 !== "undefined") { */ /** - * @property {Object} combineColors + * @typedef {Object} CombineColorsHook + * @property {any} baseColor + * @property {any} diffuse + * @property {any} ambientColor + * @property {any} ambient + * @property {any} specularColor + * @property {any} specular + * @property {any} emissive + * @property {any} opacity + * @property {function(): undefined} begin + * @property {function(): undefined} end + * @property {function(color: any): void} set + */ + +/** + * @property {CombineColorsHook} combineColors + * @beta * @description * A shader hook block that modifies how color components are combined in the fragment shader. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to control the final color output of a material. Modifications happen between the `.begin()` and `.end()` methods of the hook. * @@ -281,8 +337,127 @@ if (typeof p5 !== "undefined") { * } */ +/** + * @method instanceID + * @beta + * @description + * Returns the index of the current instance when drawing multiple copies of a + * shape with `model(count)`. The first instance has an + * ID of `0`, the second has `1`, and so on. + * + * This lets each copy of a shape behave differently. For example, you can use + * the ID to place instances at different positions, give them different colors, + * or animate them at different speeds. + * + * `instanceID()` can only be used inside a p5.strands shader callback. + * + * ```js example + * let instancesShader; + * let instance; + * let count = 5; + * + * function drawInstance() { + * sphere(15); + * } + * + * function setup() { + * createCanvas(200, 200, WEBGL); + * instance = buildGeometry(drawInstance); + * instancesShader = buildMaterialShader(drawSpaced); + * describe('Five red spheres arranged in a horizontal line.'); + * } + * + * function drawSpaced() { + * worldInputs.begin(); + * // Spread spheres evenly across the canvas based on their index + * let spacing = width / count; + * worldInputs.position.x += + * (instanceID() - (count - 1) / 2) * spacing; + * worldInputs.end(); + * } + * + * function draw() { + * background(220); + * lights(); + * noStroke(); + * fill('red'); + * shader(instancesShader); + * model(instance, count); + * } + * ``` + * + * If you are using WebGPU mode, a common pattern is to use `instanceID()` to look up data made with + * `createStorage()`. + * This lets you give each instance different properties. + * + * ```js example + * let instanceData; + * let instancesShader; + * let instance; + * let count = 5; + * + * async function setup() { + * await createCanvas(200, 200, WEBGPU); + * + * let data = []; + * for (let i = 0; i < count; i++) { + * data.push({ + * position: createVector( + * random(-1, 1) * width / 2, + * random(-1, 1) * height / 2, + * 0, + * ), + * color: color( + * random(255), + * random(255), + * random(255) + * ) + * }); + * } + * instanceData = createStorage(data); + * instance = buildGeometry(drawInstance); + * instancesShader = buildMaterialShader(drawInstances); + * describe('Five spheres at random positions, each a different random color.'); + * } + * + * function drawInstance() { + * sphere(15); + * } + * + * function drawInstances() { + * let data = uniformStorage(instanceData); + * let itemColor = sharedVec4(); + * + * worldInputs.begin(); + * let item = data[instanceID()]; + * itemColor = item.color; + * worldInputs.position += item.position; + * worldInputs.end(); + * + * finalColor.begin(); + * finalColor.set(itemColor); + * finalColor.end(); + * } + * + * function draw() { + * background(220); + * lights(); + * noStroke(); + * shader(instancesShader); + * model(instance, count); + * } + * ``` + * + * This can be paired with `buildComputeShader` + * to update the data being read. + * + * @webgpu + * @returns {*} The index of the current instance. + */ + /** * @method smoothstep + * @beta * @description * A shader function that performs smooth Hermite interpolation between `0.0` * and `1.0`. @@ -443,7 +618,27 @@ if (typeof p5 !== "undefined") { */ /** - * @property {Object} pixelInputs + * @typedef {Object} PixelInputsHook + * @property {any} normal + * @property {any} texCoord + * @property {any} ambientLight + * @property {any} ambientMaterial + * @property {any} specularMaterial + * @property {any} emissiveMaterial + * @property {any} color + * @property {any} shininess + * @property {any} metalness + * @property {any} tangent + * @property {any} center + * @property {any} position + * @property {any} strokeWeight + * @property {function(): undefined} begin + * @property {function(): undefined} end + */ + +/** + * @property {PixelInputsHook} pixelInputs + * @beta * @description * A shader hook block that modifies the properties of each pixel before the final color is calculated. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to adjust per-pixel data before lighting is applied. Modifications happen between the `.begin()` and `.end()` methods of the hook. * @@ -530,12 +725,23 @@ if (typeof p5 !== "undefined") { */ /** - * @property finalColor + * @typedef {Object} FinalColorHook + * @property {any} color + * @property {any} texCoord + * @property {function(): undefined} begin + * @property {function(): undefined} end + * @property {function(color: any): void} set + */ + +/** + * @property {FinalColorHook} finalColor + * @beta * @description * A shader hook block that modifies the final color of each pixel after all lighting is applied. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to adjust the color before it appears on the screen. Modifications happen between the `.begin()` and `.end()` methods of the hook. * * `finalColor` has the following properties: * - `color`: a four-component vector representing the pixel color (red, green, blue, alpha). + * - `texCoord`: a two-component vector representing the texture coordinates (u, v) * * Call `.set()` on the hook with a vector with four components (red, green, blue, alpha) to update the final color. * @@ -612,7 +818,18 @@ if (typeof p5 !== "undefined") { */ /** - * @property {Object} filterColor + * @typedef {Object} FilterColorHook + * @property {any} texCoord + * @property {any} canvasSize + * @property {any} texelSize + * @property {any} canvasContent + * @property {function(): undefined} begin + * @property {function(): undefined} end + * @property {function(color: any): void} set + */ + +/** + * @property {FilterColorHook} filterColor * @description * A shader hook block that sets the color for each pixel in a filter shader. This hook can be used inside `buildFilterShader()` to control the output color for each pixel. * @@ -656,7 +873,18 @@ if (typeof p5 !== "undefined") { */ /** - * @property {Object} objectInputs + * @typedef {Object} ObjectInputsHook + * @property {any} position + * @property {any} normal + * @property {any} texCoord + * @property {any} color + * @property {function(): undefined} begin + * @property {function(): undefined} end + */ + +/** + * @property {ObjectInputsHook} objectInputs + * @beta * @description * A shader hook block to modify the properties of each vertex before any transformations are applied. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to customize vertex positions, normals, texture coordinates, and colors before rendering. Modifications happen between the `.begin()` and `.end()` methods of the hook. "Object space" refers to the coordinate system of the 3D scene before any transformations, cameras, or projection transformations are applied. * @@ -697,7 +925,18 @@ if (typeof p5 !== "undefined") { */ /** - * @property {Object} cameraInputs + * @typedef {Object} CameraInputsHook + * @property {any} position + * @property {any} normal + * @property {any} texCoord + * @property {any} color + * @property {function(): undefined} begin + * @property {function(): undefined} end + */ + +/** + * @property {CameraInputsHook} cameraInputs + * @beta * @description * A shader hook block that adjusts vertex properties from the perspective of the camera. This hook can be used inside `buildMaterialShader()` and similar shader `modify()` calls to customize vertex positions, normals, texture coordinates, and colors before rendering. "Camera space" refers to the coordinate system of the 3D scene after transformations have been applied, seen relative to the camera. * @@ -740,30 +979,37 @@ if (typeof p5 !== "undefined") { */ /** - * Retrieves the current color of a given texture at given coordinates. + * Declares a storage buffer uniform inside a modify() callback, + * making a createStorage() buffer accessible in the shader. * - * The given coordinates should be between [0, 0] representing the top-left of - * the texture, and [1, 1] representing the bottom-right of the texture. + * Pass a `p5.StorageBuffer` (or a function returning one) as the second argument + * to set it as the default value, applied automatically each frame. Pass a plain + * object with the same field layout as the buffer's struct elements to declare the + * schema without binding a specific buffer. * - * The given texture could be, for example: - * * p5.Image, - * * a p5.Graphics, or - * * a p5.Framebuffer. + * When called without a name, p5.strands automatically uses the name of the + * variable it is assigned to as the uniform name. * - * The retrieved color that is returned will behave like a vec4, with components - * for red, green, blue, and alpha, each between 0.0 and 1.0. + * Note: `uniformStorage` is only available when using p5.strands. * - * Linear interpolation is used by default. For Framebuffer sources, you can - * prevent this by creating the buffer with: - * ```js - * createFramebuffer({ - * textureFiltering: NEAREST - * }) - * ``` - * This can be useful if you are using your texture to store data other than color. - * See createFramebuffer. - * - * Note: The `getTexture` function is only available when using p5.strands. + * @method uniformStorage + * @beta + * @webgpu + * @webgpuOnly + * @submodule p5.strands + * @param {String} name The name of the storage buffer uniform in the shader. + * @param {p5.StorageBuffer|Function|Object} [bufferOrSchema] A storage buffer to bind, + * a function returning a storage buffer (called each frame), or a plain object + * describing the struct field layout. + * @returns {*} A strands node representing the storage buffer. + */ +/** + * @method uniformStorage + * @param {p5.StorageBuffer|Function|Object} [bufferOrSchema] + * @returns {*} + */ + +/** * * @method getTexture * @beta @@ -873,30 +1119,170 @@ if (typeof p5 !== "undefined") { /** * @method getWorldInputs + * @beta * @param {Function} callback */ /** * @method getPixelInputs + * @beta * @param {Function} callback */ /** * @method getFinalColor + * @beta * @param {Function} callback */ /** * @method getColor + * @beta * @param {Function} callback */ /** * @method getObjectInputs + * @beta * @param {Function} callback */ /** * @method getCameraInputs + * @beta * @param {Function} callback */ + +/** + * Performs linear interpolation between two values. + * + * The `mix()` function linearly interpolates between two values based on a third + * parameter. It's a GLSL built-in function available in p5.strands shaders. + * + * The function computes: `x * (1 - a) + y * a` + * + * When `a` is 0.0, the function returns `x`. When `a` is 1.0, it returns `y`. + * Values between 0.0 and 1.0 produce a linear blend between the two values. + * + * This function works with scalars, vectors (vec2, vec3, vec4), and can also + * accept a boolean for the third parameter to select between the two values. + * + * Note: This function is only available inside shader code created with + * buildMaterialShader(), + * buildColorShader(), or similar functions. + * For regular p5.js code, use lerp() instead. + * + * @method mix + * @param {Number|p5.Vector} x first value to interpolate from. + * @param {Number|p5.Vector} y second value to interpolate to. + * @param {Number|Boolean} a interpolation amount (0.0-1.0) or boolean selector. + * @return {Number|p5.Vector} interpolated value. + * + * @example + *
+ * + * let myShader; + * + * function setup() { + * createCanvas(200, 200, WEBGL); + * myShader = buildMaterialShader(applyMix); + * describe('A sphere that transitions smoothly between red and blue.'); + * } + * + * function applyMix() { + * let factor = uniformFloat(); + * + * pixelInputs.begin(); + * // Mix between red and blue based on factor + * let red = vec3(1, 0, 0); + * let blue = vec3(0, 0, 1); + * let mixedColor = mix(red, blue, factor); + * pixelInputs.color = vec4(mixedColor, 1); + * // Set ambient color to match to avoid default ambient lighting + * pixelInputs.ambientColor = pixelInputs.color.rgb; + * pixelInputs.end(); + * } + * + * function draw() { + * background(255); + * shader(myShader); + * // Oscillate factor between 0 and 1 + * let factor = (sin(frameCount * 0.02) + 1) / 2; + * myShader.setUniform('factor', factor); + * noStroke(); + * sphere(80); + * } + * + *
+ * + * @example + *
+ * + * let myShader; + * + * function setup() { + * createCanvas(200, 200, WEBGL); + * myShader = buildMaterialShader(positionMix); + * describe('A sphere with vertices that blend between two positions.'); + * } + * + * function positionMix() { + * let time = uniformFloat(); + * + * worldInputs.begin(); + * // Blend vertex position between original and modified + * let originalPos = worldInputs.position; + * let modifiedPos = originalPos + vec3(0, sin(time * 0.001) * 20, 0); + * let factor = (sin(worldInputs.position.x * 0.1) + 1) / 2; + * worldInputs.position = mix(originalPos, modifiedPos, factor); + * worldInputs.end(); + * } + * + * function draw() { + * background(220); + * shader(myShader); + * myShader.setUniform('time', millis()); + * lights(); + * noStroke(); + * fill('red'); + * sphere(70); + * } + * + *
+ * + * @example + *
+ * + * let myShader; + * + * function setup() { + * createCanvas(200, 200, WEBGL); + * myShader = buildMaterialShader(gradientMix); + * describe('A torus with a color gradient created using mix().'); + * } + * + * function gradientMix() { + * pixelInputs.begin(); + * // Create a gradient based on texture coordinates + * let gradient = pixelInputs.texCoord.x; + * let color1 = vec3(1, 0.5, 0); // Orange + * let color2 = vec3(0.5, 0, 1); // Purple + * let mixedColor = mix(color1, color2, gradient); + * pixelInputs.color = vec4(mixedColor, 1); + * // Set ambient color to match to avoid default ambient lighting + * pixelInputs.ambientColor = pixelInputs.color.rgb; + * pixelInputs.end(); + * } + * + * function draw() { + * background(200); + * shader(myShader); + * lights(); + * noStroke(); + * rotateX(frameCount * 0.01); + * rotateY(frameCount * 0.01); + * torus(60, 20); + * } + * + *
+ */ diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 52001b3c99..8675dc69b6 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -10,11 +10,13 @@ import { OpCode, StatementType, NodeType, + HOOK_PARAM_PREFIX, // isNativeType } from './ir_types' import { strandsBuiltinFunctions } from './strands_builtins' import { StrandsConditional } from './strands_conditionals' import { StrandsFor } from './strands_for' +import { buildTernary } from './strands_ternary' import * as CFG from './ir_cfg' import * as DAG from './ir_dag'; import * as FES from './strands_FES' @@ -194,6 +196,10 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return new StrandsFor(strandsContext, initialCb, conditionCb, updateCb, bodyCb, initialVars).build(); }; augmentFn(fn, p5, 'strandsFor', p5.strandsFor); + p5.strandsTernary = function(condition, ifTrue, ifFalse) { + return buildTernary(strandsContext, condition, ifTrue, ifFalse); + }; + augmentFn(fn, p5, 'strandsTernary', p5.strandsTernary); p5.strandsEarlyReturn = function(value) { const { dag, cfg } = strandsContext; @@ -214,7 +220,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { const nodeData = DAG.createNodeData({ nodeType: NodeType.STATEMENT, statementType: StatementType.EARLY_RETURN, - dependsOn: [valueNode.id] + dependsOn: value !== undefined ? [valueNode.id] : [] }); const earlyReturnID = DAG.getOrCreateNode(dag, nodeData); CFG.recordInBasicBlock(cfg, cfg.currentBlock, earlyReturnID); @@ -279,6 +285,33 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { } } + // Alias lerp to GLSL mix in strands context + const originalLerp = fn.lerp; + augmentFn(fn, p5, 'lerp', function (...args) { + if (strandsContext.active) { + return fn.mix(...args); + } else { + return originalLerp.apply(this, args); + } + }); + + const originalMap = fn.map; + augmentFn(fn, p5, 'map', function (...args) { + if (!strandsContext.active) { + return originalMap.apply(this, args); + } + const [n, start1, stop1, start2, stop2, withinBounds] = args; + const nNode = p5.strandsNode(n); + const start1Node = p5.strandsNode(start1); + const stop1Node = p5.strandsNode(stop1); + const t = nNode.sub(start1Node).div(stop1Node.sub(start1Node)); + const result = this.mix(start2, stop2, t); + if (withinBounds) { + return this.clamp(result, this.min(start2, stop2), this.max(start2, stop2)); + } + return result; + }); + augmentFn(fn, p5, 'getTexture', function (...rawArgs) { if (strandsContext.active) { const { id, dimension } = strandsContext.backend.createGetTextureCall(strandsContext, rawArgs); @@ -303,6 +336,8 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { // Add noise function with backend-agnostic implementation const originalNoise = fn.noise; const originalNoiseDetail = fn.noiseDetail; + const originalRandom = fn.random; + const originalRandomSeed = fn.randomSeed; const originalMillis = fn.millis; strandsContext._noiseOctaves = null; @@ -322,9 +357,10 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return originalNoise.apply(this, args); // fallback to regular p5.js noise } // Get noise shader snippet from the current renderer - const noiseSnippet = this._renderer.getNoiseShaderSnippet(); + const noiseSnippet = strandsContext.backend.getNoiseShaderSnippet(); strandsContext.vertexDeclarations.add(noiseSnippet); strandsContext.fragmentDeclarations.add(noiseSnippet); + strandsContext.computeDeclarations.add(noiseSnippet); // Make each input into a strands node so that we can check their dimensions const strandsArgs = args.flat().map(arg => p5.strandsNode(arg)); @@ -366,6 +402,84 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return createStrandsNode(id, dimension, strandsContext); }); + strandsContext._randomSeed = null; + + augmentFn(fn, p5, 'randomSeed', function (seed) { + if (!strandsContext.active) { + return originalRandomSeed.apply(this, arguments); + } + strandsContext._randomSeed = seed; + }); + + augmentFn(fn, p5, 'random', function (...args) { + if (!strandsContext.active) { + return originalRandom.apply(this, args); + } + + const randomVertSnippet = strandsContext.backend.getRandomVertexShaderSnippet(); + const randomFragSnippet = strandsContext.backend.getRandomFragmentShaderSnippet(); + + strandsContext.vertexDeclarations.add(randomVertSnippet); + strandsContext.fragmentDeclarations.add(randomFragSnippet); + + if (strandsContext.backend.getRandomComputeShaderSnippet) { + const randomComputeSnippet = strandsContext.backend.getRandomComputeShaderSnippet(); + strandsContext.computeDeclarations.add(randomComputeSnippet); + } + + let seedNode; + if (strandsContext._randomSeed !== null && strandsContext._randomSeed.isStrandsNode) { + seedNode = strandsContext._randomSeed; + } else { + const userSeed = strandsContext._randomSeed; + seedNode = getOrCreateUniformNode( + strandsContext, + '_p5_randomSeed', + DataType.float1, + userSeed !== null + ? () => userSeed + : () => performance.now(), + ); + } + + // The shader-side random() owns a private per-invocation counter, so a + // single AST node still produces distinct values across runtime loop + // iterations. We just pass the seed. + const nodeArgs = [seedNode]; + const randomOverloads = [{ + params: [DataType.float1], + returnType: DataType.float1, + }]; + + if (args.length === 0) { + const { id, dimension } = build.functionCallNode(strandsContext, 'random', nodeArgs, { + overloads: randomOverloads, + }); + return createStrandsNode(id, dimension, strandsContext); + } else if (args.length === 1) { + // random(max) β†’ [0, max) + const rawNode = build.functionCallNode(strandsContext, 'random', nodeArgs, { + overloads: randomOverloads, + }); + const rawStrandsNode = createStrandsNode(rawNode.id, rawNode.dimension, strandsContext); + return rawStrandsNode.mult(p5.strandsNode(args[0])); + } else if (args.length === 2) { + // random(min, max) β†’ [min, max) + const rawNode = build.functionCallNode(strandsContext, 'random', nodeArgs, { + overloads: randomOverloads, + }); + const rawStrandsNode = createStrandsNode(rawNode.id, rawNode.dimension, strandsContext); + const minNode = p5.strandsNode(args[0]); + const maxNode = p5.strandsNode(args[1]); + // min + raw * (max - min) + return rawStrandsNode.mult(maxNode.sub(minNode)).add(minNode); + } else { + p5._friendlyError( + `It looks like you've called random() with ${args.length} arguments. In strands, random() supports 0, 1, or 2 numeric arguments.` + ); + } + }); + augmentFn(fn, p5, 'millis', function (...args) { if (!strandsContext.active) { return originalMillis.apply(this, args); @@ -477,6 +591,53 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { } }); } + + // Storage buffer uniform function for compute shaders + fn.uniformStorage = function(name, bufferOrSchema) { + let schema = null; + let defaultValue = null; + + // If it's a function, evaluate it immediately to infer schema, + // then store the function so it gets called each frame. + let value = bufferOrSchema; + if (typeof bufferOrSchema === 'function') { + value = bufferOrSchema(); + if (value?._schema) { + defaultValue = bufferOrSchema; + } + } + + if (value?._schema) { + // Struct storage buffer with pre-computed schema + schema = value._schema; + if (defaultValue === null) defaultValue = value; + } else if (value && typeof value === 'object' && !value._isStorageBuffer) { + // Plain object schema template -- only used to infer struct layout, not as a default value + schema = strandsContext.renderer?._inferStructSchema(value) ?? null; + } else if (value?._isStorageBuffer) { + defaultValue = bufferOrSchema; + } + + const { id, dimension } = build.variableNode( + strandsContext, + { baseType: 'storage', dimension: 1 }, + name + ); + strandsContext.uniforms.push({ + name, + typeInfo: { baseType: 'storage', dimension: 1, schema }, + defaultValue, + }); + + // Create StrandsNode with _originalIdentifier set (like varying variables) + // This enables proper assignment node creation and ordering preservation + const node = createStrandsNode(id, dimension, strandsContext); + node._originalIdentifier = name; + node._originalBaseType = 'storage'; + node._originalDimension = 1; + node._schema = schema; + return node; + }; } ////////////////////////////////////////////// // Per-Hook functions @@ -487,7 +648,7 @@ function createHookArguments(strandsContext, parameters){ for (const param of parameters) { if(isStructType(param.type)) { const structTypeInfo = structType(param); - const { id, dimension } = build.structInstanceNode(strandsContext, structTypeInfo, param.name, []); + const { id, dimension } = build.structInstanceNode(strandsContext, structTypeInfo, `${HOOK_PARAM_PREFIX}${param.name}`, []); const structNode = createStrandsNode(id, dimension, strandsContext).withStructProperties( structTypeInfo.properties.map(prop => prop.name) ); @@ -500,7 +661,7 @@ function createHookArguments(strandsContext, parameters){ const oldDeps = dag.dependsOn[structNode.id]; const newDeps = oldDeps.slice(); newDeps[i] = newFieldID; - const rebuilt = build.structInstanceNode(strandsContext, structTypeInfo, param.name, newDeps); + const rebuilt = build.structInstanceNode(strandsContext, structTypeInfo, `${HOOK_PARAM_PREFIX}${param.name}`, newDeps); structNode.id = rebuilt.id; }; // TODO: implement member access operations @@ -521,7 +682,7 @@ function createHookArguments(strandsContext, parameters){ newValueID = newVal.id; } newDependsOn[i] = newValueID; - const newStructInfo = build.structInstanceNode(strandsContext, structTypeInfo, param.name, newDependsOn); + const newStructInfo = build.structInstanceNode(strandsContext, structTypeInfo, `${HOOK_PARAM_PREFIX}${param.name}`, newDependsOn); structNode.id = newStructInfo.id; } }) @@ -537,7 +698,7 @@ function createHookArguments(strandsContext, parameters){ throw new Error(`Missing dataType for parameter ${param.name} of type ${param.type.typeName}`); } const typeInfo = param.type.dataType; - const { id, dimension } = build.variableNode(strandsContext, typeInfo, param.name); + const { id, dimension } = build.variableNode(strandsContext, typeInfo, `${HOOK_PARAM_PREFIX}${param.name}`); const arg = createStrandsNode(id, dimension, strandsContext); args.push(arg); } @@ -595,10 +756,14 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { const fragmentHooksWithContext = Object.fromEntries( Object.entries(shader.hooks.fragment).map(([name, hook]) => [name, { ...hook, shaderContext: 'fragment' }]) ); + const computeHooksWithContext = Object.fromEntries( + Object.entries(shader.hooks.compute).map(([name, hook]) => [name, { ...hook, shaderContext: 'compute' }]) + ); const availableHooks = { ...vertexHooksWithContext, ...fragmentHooksWithContext, + ...computeHooksWithContext, } const hookTypes = Object.keys(availableHooks).map(name => shader.hookTypes(name)); @@ -628,9 +793,11 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { if (numStructArgs === 1) { argIdx = hookType.parameters.findIndex(param => param.type.properties); } + hook._properties = []; for (let i = 0; i < args.length; i++) { if (i === argIdx) { for (const key of args[argIdx].structProperties || []) { + hook._properties.push(key); Object.defineProperty(hook, key, { get() { return args[argIdx][key]; @@ -645,6 +812,7 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { hook.set(args[argIdx]); } } else { + hook._properties.push(hookType.parameters[i].name); hook[hookType.parameters[i].name] = args[i]; } } @@ -716,17 +884,21 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { return newStruct.id; } } + else if (!expectedReturnType.dataType || expectedReturnType.typeName?.trim() === 'void') { + return null; + } else /*if(isNativeType(expectedReturnType.typeName))*/ { - if (!expectedReturnType.dataType) { - throw new Error(`Missing dataType for return type ${expectedReturnType.typeName}`); - } const expectedTypeInfo = expectedReturnType.dataType; return enforceReturnTypeMatch(strandsContext, expectedTypeInfo, retNode, hookType.name); } } for (const { valueNode, earlyReturnID } of hook.earlyReturns) { const id = handleRetVal(valueNode); - dag.dependsOn[earlyReturnID] = [id]; + if (id !== null) { + dag.dependsOn[earlyReturnID] = [id]; + } else { + dag.dependsOn[earlyReturnID] = []; + } } rootNodeID = userReturned ? handleRetVal(userReturned) : undefined; const fullHookName = `${hookType.returnType.typeName} ${hookType.name}`; @@ -735,7 +907,7 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { hookType, entryBlockID, rootNodeID, - shaderContext: hookInfo?.shaderContext, // 'vertex' or 'fragment' + shaderContext: hookInfo?.shaderContext, // 'vertex', 'fragment', or 'compute' }); CFG.popBlock(cfg); }; diff --git a/src/strands/strands_codegen.js b/src/strands/strands_codegen.js index 615b248e67..38e24c511e 100644 --- a/src/strands/strands_codegen.js +++ b/src/strands/strands_codegen.js @@ -7,18 +7,26 @@ export function generateShaderCode(strandsContext) { cfg, backend, vertexDeclarations, - fragmentDeclarations + fragmentDeclarations, + computeDeclarations } = strandsContext; const hooksObj = { uniforms: {}, + storageUniforms: {}, varyingVariables: [], }; for (const {name, typeInfo, defaultValue} of strandsContext.uniforms) { - const key = backend.generateHookUniformKey(name, typeInfo); - if (key !== null) { - hooksObj.uniforms[key] = defaultValue; + if (typeInfo.baseType === 'storage') { + if (defaultValue !== null && defaultValue !== undefined) { + hooksObj.storageUniforms[name] = defaultValue; + } + } else { + const key = backend.generateHookUniformKey(name, typeInfo); + if (key !== null) { + hooksObj.uniforms[key] = defaultValue; + } } } @@ -27,6 +35,11 @@ export function generateShaderCode(strandsContext) { backend.addTextureBindingsToDeclarations(strandsContext); } + // Add storage buffer bindings to declarations for WebGPU backend + if (backend.addStorageBufferBindingsToDeclarations) { + backend.addStorageBufferBindingsToDeclarations(strandsContext); + } + for (const { hookType, rootNodeID, entryBlockID, shaderContext } of strandsContext.hooks) { const generationContext = { indent: 1, @@ -51,14 +64,13 @@ export function generateShaderCode(strandsContext) { let returnType; if (hookType.returnType.properties) { returnType = structType(hookType.returnType); + } else if (!hookType.returnType.dataType || hookType.returnType.typeName?.trim() === 'void') { + returnType = null; } else { - if (!hookType.returnType.dataType) { - throw new Error(`Missing dataType for return type ${hookType.returnType.typeName}`); - } returnType = hookType.returnType.dataType; } - if (rootNodeID) { + if (rootNodeID !== undefined) { backend.generateReturnStatement(strandsContext, generationContext, rootNodeID, returnType); } hooksObj[`${hookType.returnType.typeName} ${hookType.name}`] = [firstLine, ...generationContext.codeLines, '}'].join('\n'); @@ -81,8 +93,14 @@ export function generateShaderCode(strandsContext) { } } + // Register instanceID varying if used in a fragment hook + if (strandsContext._instanceIDUsedInFragment) { + hooksObj.instanceIDVarying = backend.generateInstanceIDVarying(); + } + hooksObj.vertexDeclarations = [...vertexDeclarations].join('\n'); hooksObj.fragmentDeclarations = [...fragmentDeclarations].join('\n'); + hooksObj.computeDeclarations = [...computeDeclarations].join('\n'); return hooksObj; } diff --git a/src/strands/strands_node.js b/src/strands/strands_node.js index 9eb994f4c3..f7638855bc 100644 --- a/src/strands/strands_node.js +++ b/src/strands/strands_node.js @@ -1,4 +1,4 @@ -import { swizzleTrap, primitiveConstructorNode, variableNode } from './ir_builders'; +import { swizzleTrap, primitiveConstructorNode, variableNode, arrayAccessNode, arrayAssignmentNode, createStructArrayElementProxy } from './ir_builders'; import { BaseType, NodeType, OpCode } from './ir_types'; import { getNodeDataFromID, createNodeData, getOrCreateNode } from './ir_dag'; import { recordInBasicBlock } from './ir_cfg'; @@ -8,6 +8,9 @@ export class StrandsNode { this.strandsContext = strandsContext; this.dimension = dimension; this.structProperties = null; + // Schema for struct storage buffers (set by uniformStorage when buffer has a struct layout). + // When set, buf.get(idx) returns a field proxy instead of a scalar StrandsNode. + this._schema = null; this.isStrandsNode = true; // Store original identifier for varying variables @@ -161,6 +164,55 @@ export class StrandsNode { return this; } + + get(index) { + const nodeData = getNodeDataFromID(this.strandsContext.dag, this.id); + + // Validate baseType is 'storage' + // For struct storage buffers, return a proxy with per-field getters/setters + if (nodeData.baseType === 'storage' && this._schema) { + return createStructArrayElementProxy(this.strandsContext, this, index, this._schema); + } + + // Create array access node for storage and non-storage (vector) access + const { id, dimension } = arrayAccessNode( + this.strandsContext, + this, + index, + 'read' + ); + return createStrandsNode(id, dimension, this.strandsContext); + } + + set(index, value) { + // Validate baseType is 'storage' and has _originalIdentifier + const nodeData = getNodeDataFromID(this.strandsContext.dag, this.id); + if (nodeData.baseType !== 'storage') { + throw new Error('set() can only be used on storage buffers'); + } + if (!this._originalIdentifier) { + throw new Error('set() can only be used on storage buffers with an identifier'); + } + + // If value is a plain object (struct literal), expand to per-field assignments + // e.g. buf[idx] = { position: pos, velocity: vel } + // becomes buf[idx].position = pos; buf[idx].velocity = vel; + if (value !== null && typeof value === 'object' && !value.isStrandsNode && this._schema) { + const proxy = createStructArrayElementProxy(this.strandsContext, this, index, this._schema); + for (const [fieldName, fieldValue] of Object.entries(value)) { + proxy[fieldName] = fieldValue; + } + return this; + } + + // Create array assignment node: buffer.set(index, value) -> buffer[index] = value + // This creates an ASSIGNMENT node and records it in the CFG basic block + // CFG preserves sequential order, preventing reordering of assignments + arrayAssignmentNode(this.strandsContext, this, index, value); + + // Return this for chaining + return this; + } } export function createStrandsNode(id, dimension, strandsContext, onRebind) { return new Proxy( diff --git a/src/strands/strands_ternary.js b/src/strands/strands_ternary.js new file mode 100644 index 0000000000..dcd84522ce --- /dev/null +++ b/src/strands/strands_ternary.js @@ -0,0 +1,53 @@ +import * as DAG from './ir_dag'; +import * as CFG from './ir_cfg'; +import { NodeType, OpCode, BaseType } from './ir_types'; +import { createStrandsNode } from './strands_node'; +import * as FES from './strands_FES'; + +export function buildTernary(strandsContext, condition, ifTrue, ifFalse) { + const { dag, cfg, p5 } = strandsContext; + + // Ensure all inputs are StrandsNodes + const condNode = condition?.isStrandsNode ? condition : p5.strandsNode(condition); + const trueNode = ifTrue?.isStrandsNode ? ifTrue : p5.strandsNode(ifTrue); + const falseNode = ifFalse?.isStrandsNode ? ifFalse : p5.strandsNode(ifFalse); + + // Get type info for both nodes + let trueType = DAG.extractNodeTypeInfo(dag, trueNode.id); + let falseType = DAG.extractNodeTypeInfo(dag, falseNode.id); + + // Propagate type from the known branch to any ASSIGN_ON_USE branch + if (trueType.baseType === BaseType.ASSIGN_ON_USE && falseType.baseType !== BaseType.ASSIGN_ON_USE) { + DAG.propagateTypeToAssignOnUse(dag, trueNode.id, falseType.baseType, falseType.dimension); + trueType = DAG.extractNodeTypeInfo(dag, trueNode.id); + } else if (falseType.baseType === BaseType.ASSIGN_ON_USE && trueType.baseType !== BaseType.ASSIGN_ON_USE) { + DAG.propagateTypeToAssignOnUse(dag, falseNode.id, trueType.baseType, trueType.dimension); + falseType = DAG.extractNodeTypeInfo(dag, falseNode.id); + } + + // After ASSIGN_ON_USE propagation, if both types are known, they must match + if ( + trueType.baseType !== BaseType.ASSIGN_ON_USE && + falseType.baseType !== BaseType.ASSIGN_ON_USE && + (trueType.baseType !== falseType.baseType || trueType.dimension !== falseType.dimension) + ) { + FES.userError('type error', + 'The true and false branches of a ternary expression must have the same type. ' + + `Right now, the true branch is a ${trueType.baseType}${trueType.dimension}, and the false branch is a ${falseType.baseType}${falseType.dimension}.` + ); + } + + const resultType = trueType; + + const nodeData = DAG.createNodeData({ + nodeType: NodeType.OPERATION, + opCode: OpCode.Nary.TERNARY, + dependsOn: [condNode.id, trueNode.id, falseNode.id], + baseType: resultType.baseType, + dimension: resultType.dimension, + }); + + const id = DAG.getOrCreateNode(dag, nodeData); + CFG.recordInBasicBlock(cfg, cfg.currentBlock, id); + return createStrandsNode(id, resultType.dimension, strandsContext); +} diff --git a/src/strands/strands_transpiler.js b/src/strands/strands_transpiler.js index 836a177c6c..ff3a4e2f88 100644 --- a/src/strands/strands_transpiler.js +++ b/src/strands/strands_transpiler.js @@ -2,6 +2,7 @@ import { parse } from 'acorn'; import { ancestor, recursive } from 'acorn-walk'; import escodegen from 'escodegen'; import { UnarySymbolToName } from './ir_types'; +import * as FES from './strands_FES'; let blockVarCounter = 0; let loopVarCounter = 0; function replaceBinaryOperator(codeSource) { @@ -40,6 +41,50 @@ function nodeIsUniform(ancestor) { ); } +function nodeIsUniformCallbackFn(node, names) { + if (!names?.size) return false; + if (node.type === 'FunctionDeclaration' && names.has(node.id?.name)) return true; + if ( + node.type === 'VariableDeclarator' && names.has(node.id?.name) && + (node.init?.type === 'FunctionExpression' || node.init?.type === 'ArrowFunctionExpression') + ) { + return true; + } + return false; +} + +function collectUniformCallbackNames(ast) { + // Sub-pass 1: collect all named function definitions + const namedFunctions = new Set(); + ancestor(ast, { + FunctionDeclaration(node) { + if (node.id) namedFunctions.add(node.id.name); + }, + VariableDeclarator(node) { + if ( + node.id?.type === 'Identifier' && + (node.init?.type === 'FunctionExpression' || node.init?.type === 'ArrowFunctionExpression') + ) { + namedFunctions.add(node.id.name); + } + } + }); + // Sub-pass 2: find which of those names are passed as uniform call arguments + const names = new Set(); + ancestor(ast, { + CallExpression(node) { + if (nodeIsUniform(node)) { + for (const arg of node.arguments) { + if (arg.type === 'Identifier' && namedFunctions.has(arg.name)) { + names.add(arg.name); + } + } + } + } + }); + return names; +} + function nodeIsVarying(node) { return node && node.type === 'CallExpression' && ( @@ -54,7 +99,64 @@ function nodeIsVarying(node) { ) ); } +// Convert static member expressions into dotted paths such as +// `loopProtect.protect` so loop-protection calls can be matched reliably. +function getMemberExpressionPath(node) { + if (node?.type === 'Identifier') return node.name; + + // Computed properties like `obj[prop]` are not safe to match as fixed paths. + if (node?.type !== 'MemberExpression' || node.computed) return null; + + const objectPath = getMemberExpressionPath(node.object); + const propertyName = node.property?.name; + + return objectPath && propertyName + ? `${objectPath}.${propertyName}` + : null; +} + +// Detect calls added by loop protection before Strands tries to transpile them. +function isLoopProtectionCall(node) { + if (node?.type !== 'CallExpression') return false; + + const path = getMemberExpressionPath(node.callee); + + if (!path) return false; + + return ( + path === 'loopProtect.protect' || + path.endsWith('.loopProtect') || + path.endsWith('.loopProtect.protect') + ); +} + +// Scan AST for loop-protection injection and throw with `// noprotect` hint. +function throwIfLoopProtectionInserted(ast) { + let found = false; + + ancestor(ast, { + CallExpression(node) { + if (isLoopProtectionCall(node)) { + found = true; + } + }, + LogicalExpression(node) { + // Loop protection may appear as the right side of a short-circuit check. + if ( + node.right?.type === 'CallExpression' && + isLoopProtectionCall(node.right) + ) { + found = true; + } + } + }); + if (found) { + FES.internalError( + 'loop protection error Loop protection code detected. Add `// noprotect` at the top of your sketch and run again.' + ); + } +} // Helper function to check if a statement is a variable declaration with strands control flow init function statementContainsStrandsControlFlow(stmt) { // Check for variable declarations with strands control flow init @@ -191,9 +293,134 @@ function replaceReferences(node, tempVarMap) { internalReplaceReferences(node); } +function replaceIdentifierReferences(node, oldName, newName) { + if (!node || typeof node !== 'object') return node; + + const replaceInNode = (n) => { + if (!n || typeof n !== 'object') return n; + if (n.type === 'Identifier' && n.name === oldName) { + return { ...n, name: newName }; + } + const newNode = { ...n }; + for (const key in n) { + if (n.hasOwnProperty(key) && key !== 'parent') { + if (Array.isArray(n[key])) { + newNode[key] = n[key].map(replaceInNode); + } else if (typeof n[key] === 'object') { + newNode[key] = replaceInNode(n[key]); + } + } + } + return newNode; + }; + + return replaceInNode(node); +} + +// Shared handler for both BinaryExpression and LogicalExpression β€” +// both follow the same operator-to-method-call transformation pattern. +function transformBinaryOrLogical(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } + node.left = { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: '__p5.strandsNode', + }, + arguments: [node.left] + }; + node.type = 'CallExpression'; + node.callee = { + type: 'MemberExpression', + object: node.left, + property: { + type: 'Identifier', + name: replaceBinaryOperator(node.operator), + }, + }; + node.arguments = [node.right]; +} + +// Shared helper used by both IfStatement and ForStatement handlers. +// Adds temp variable copies, replaces references, and appends a return +// statement to a branch/loop function body. +// sourcePrefix: the root identifier to read from ('vars' for loops, +// null for if-branches where we read directly from the outer variable). +function addCopyingAndReturn(functionBody, varsToReturn, sourcePrefix = null) { + if (functionBody.type !== 'BlockStatement') return; + + const tempVarMap = new Map(); + const copyStatements = []; + + for (const varPath of varsToReturn) { + const parts = varPath.split('.'); + const tempName = `__copy_${parts.join('_')}_${blockVarCounter++}`; + tempVarMap.set(varPath, tempName); + + // If sourcePrefix is set (loop case), read from vars.x.y + // Otherwise (if-branch case), read directly from x.y + let sourceExpr = sourcePrefix + ? { type: 'Identifier', name: sourcePrefix } + : { type: 'Identifier', name: parts[0] }; + + const pathParts = sourcePrefix ? parts : parts.slice(1); + for (const part of pathParts) { + sourceExpr = { + type: 'MemberExpression', + object: sourceExpr, + property: { type: 'Identifier', name: part }, + computed: false + }; + } + + copyStatements.push({ + type: 'VariableDeclaration', + declarations: [{ + type: 'VariableDeclarator', + id: { type: 'Identifier', name: tempName }, + init: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: sourceExpr, + property: { type: 'Identifier', name: 'copy' }, + computed: false + }, + arguments: [] + } + }], + kind: 'let' + }); + } + + functionBody.body.forEach(node => replaceReferences(node, tempVarMap)); + functionBody.body.unshift(...copyStatements); + + const returnObj = { + type: 'ObjectExpression', + properties: Array.from(varsToReturn).map(varPath => ({ + type: 'Property', + key: { type: 'Literal', value: varPath }, + value: { type: 'Identifier', name: tempVarMap.get(varPath) }, + kind: 'init', + computed: false, + shorthand: false + })) + }; + + functionBody.body.push({ + type: 'ReturnStatement', + argument: returnObj + }); +} + const ASTCallbacks = { - UnaryExpression(node, _state, ancestors) { - if (ancestors.some(nodeIsUniform)) { return; } + UnaryExpression(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } const unaryFnName = UnarySymbolToName[node.operator]; const standardReplacement = (node) => { node.type = 'CallExpression' @@ -212,7 +439,7 @@ const ASTCallbacks = { ]; let isSwizzle = swizzleSets.some(set => [...property].every(char => set.includes(char)) - ) && node.argument.type === 'MemberExpression'; + ) && node.argument.type === 'MemberExpression' && !node.argument.computed; if (isSwizzle) { node.type = 'MemberExpression'; node.object = { @@ -236,8 +463,10 @@ const ASTCallbacks = { delete node.argument; delete node.operator; }, - BreakStatement(node, _state, ancestors) { - if (ancestors.some(nodeIsUniform)) { return; } + BreakStatement(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } node.callee = { type: 'Identifier', name: '__p5.break' @@ -245,8 +474,39 @@ const ASTCallbacks = { node.arguments = []; node.type = 'CallExpression'; }, - VariableDeclarator(node, _state, ancestors) { - if (ancestors.some(nodeIsUniform)) { return; } + MemberExpression(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } + // Skip sets -- these will be converted to .set() method + // calls at the AssignmentExpression level + if ( + ancestors.at(-2)?.type === 'AssignmentExpression' && + ancestors.at(-2).left === node + ) { + return; + } + if (node.computed) { + const callee = node.object; + const member = node.property; + node.computed = undefined; + node.object = undefined; + node.callee = { + type: 'MemberExpression', + object: callee, + property: { + type: 'Identifier', + name: 'get', + } + }; + node.arguments = [member]; + node.type = 'CallExpression'; + } + }, + VariableDeclarator(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } if (nodeIsUniform(node.init)) { // Only inject the variable name if the first argument isn't already a string if (node.init.arguments.length === 0 || @@ -271,16 +531,18 @@ const ASTCallbacks = { value: node.id.name } node.init.arguments.unshift(varyingNameLiteral); - _state.varyings[node.id.name] = varyingNameLiteral; + state.varyings[node.id.name] = varyingNameLiteral; } else { // Still track it as a varying even if name wasn't injected - _state.varyings[node.id.name] = node.init.arguments[0]; + state.varyings[node.id.name] = node.init.arguments[0]; } } }, - Identifier(node, _state, ancestors) { - if (ancestors.some(nodeIsUniform)) { return; } - if (_state.varyings[node.name] + Identifier(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } + if (state.varyings[node.name] && !ancestors.some(a => a.type === 'AssignmentExpression' && a.left === node) ) { node.type = 'CallExpression'; @@ -300,8 +562,18 @@ const ASTCallbacks = { }, // The callbacks for AssignmentExpression and BinaryExpression handle // operator overloading including +=, *= assignment expressions - ArrayExpression(node, _state, ancestors) { - if (ancestors.some(nodeIsUniform)) { return; } + ArrayExpression(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } + + if (node.elements.length < 2 || node.elements.length > 4) { + FES.userError( + 'type error', + `Array literals in shader functions are transpiled to vectors and must have 2-4 elements (got ${node.elements.length}).` + ); + } + const original = JSON.parse(JSON.stringify(node)); node.type = 'CallExpression'; node.callee = { @@ -310,8 +582,10 @@ const ASTCallbacks = { }; node.arguments = [original]; }, - AssignmentExpression(node, _state, ancestors) { - if (ancestors.some(nodeIsUniform)) { return; } + AssignmentExpression(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } const unsafeTypes = ['Literal', 'ArrayExpression', 'Identifier']; if (node.operator !== '=') { const methodName = replaceBinaryOperator(node.operator.replace('=','')); @@ -340,7 +614,7 @@ const ASTCallbacks = { node.right = rightReplacementNode; } // Handle direct varying variable assignment: myVarying = value - if (_state.varyings[node.left.name]) { + if (state.varyings[node.left.name]) { node.type = 'ExpressionStatement'; node.expression = { type: 'CallExpression', @@ -361,10 +635,31 @@ const ASTCallbacks = { // Handle swizzle assignment to varying variable: myVarying.xyz = value // Note: node.left.object might be worldPos.getValue() due to prior Identifier transformation else if (node.left.type === 'MemberExpression') { + if (node.left.computed) { + const source = node.left; + const value = node.right; + const callee = source.object; + const member = source.property; + node.right = undefined; + node.left = undefined; + node.operator = undefined; + node.callee = { + type: 'MemberExpression', + object: callee, + property: { + type: 'Identifier', + name: 'set' + } + }; + node.arguments = [member, value]; + node.type = 'CallExpression'; + return; + } + let varyingName = null; // Check if it's a direct identifier: myVarying.xyz - if (node.left.object.type === 'Identifier' && _state.varyings[node.left.object.name]) { + if (node.left.object.type === 'Identifier' && state.varyings[node.left.object.name]) { varyingName = node.left.object.name; } // Check if it's a getValue() call: myVarying.getValue().xyz @@ -372,7 +667,7 @@ const ASTCallbacks = { node.left.object.callee?.type === 'MemberExpression' && node.left.object.callee.property?.name === 'getValue' && node.left.object.callee.object?.type === 'Identifier' && - _state.varyings[node.left.object.callee.object.name]) { + state.varyings[node.left.object.callee.object.name]) { varyingName = node.left.object.callee.object.name; } @@ -403,70 +698,30 @@ const ASTCallbacks = { } } }, - BinaryExpression(node, _state, ancestors) { - // Don't convert uniform default values to node methods, as - // they should be evaluated at runtime, not compiled. - if (ancestors.some(nodeIsUniform)) { return; } - // If the left hand side of an expression is one of these types, - // we should construct a node from it. - const unsafeTypes = ['Literal', 'ArrayExpression', 'Identifier']; - if (unsafeTypes.includes(node.left.type)) { - const leftReplacementNode = { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: '__p5.strandsNode', - }, - arguments: [node.left] - } - node.left = leftReplacementNode; + BinaryExpression: transformBinaryOrLogical, + LogicalExpression: transformBinaryOrLogical, + + + ConditionalExpression(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; } - // Replace the binary operator with a call expression - // in other words a call to BaseNode.mult(), .div() etc. + // Transform condition ? consequent : alternate + // into __p5.strandsTernary(condition, consequent, alternate) + const test = node.test; + const consequent = node.consequent; + const alternate = node.alternate; node.type = 'CallExpression'; - node.callee = { - type: 'MemberExpression', - object: node.left, - property: { - type: 'Identifier', - name: replaceBinaryOperator(node.operator), - }, - }; - node.arguments = [node.right]; + node.callee = { type: 'Identifier', name: '__p5.strandsTernary' }; + node.arguments = [test, consequent, alternate]; + delete node.test; + delete node.consequent; + delete node.alternate; }, - LogicalExpression(node, _state, ancestors) { - // Don't convert uniform default values to node methods, as - // they should be evaluated at runtime, not compiled. - if (ancestors.some(nodeIsUniform)) { return; } - // If the left hand side of an expression is one of these types, - // we should construct a node from it. - const unsafeTypes = ['Literal', 'ArrayExpression', 'Identifier']; - if (unsafeTypes.includes(node.left.type)) { - const leftReplacementNode = { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: '__p5.strandsNode', - }, - arguments: [node.left] - } - node.left = leftReplacementNode; + IfStatement(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; } - // Replace the logical operator with a call expression - // in other words a call to BaseNode.or(), .and() etc. - node.type = 'CallExpression'; - node.callee = { - type: 'MemberExpression', - object: node.left, - property: { - type: 'Identifier', - name: replaceBinaryOperator(node.operator), - }, - }; - node.arguments = [node.right]; - }, - IfStatement(node, _state, ancestors) { - if (ancestors.some(nodeIsUniform)) { return; } // Transform if statement into strandsIf() call // The condition is evaluated directly, not wrapped in a function const condition = node.test; @@ -570,70 +825,6 @@ const ASTCallbacks = { analyzeBranch(thenFunction.body); analyzeBranch(elseFunction.body); if (assignedVars.size > 0) { - // Add copying, reference replacement, and return statements to branch functions - const addCopyingAndReturn = (functionBody, varsToReturn) => { - if (functionBody.type === 'BlockStatement') { - // Create temporary variables and copy statements - const tempVarMap = new Map(); // property path -> temp name - const copyStatements = []; - for (const varPath of varsToReturn) { - const parts = varPath.split('.'); - const tempName = `__copy_${parts.join('_')}_${blockVarCounter++}`; - tempVarMap.set(varPath, tempName); - - // Build the member expression for the property path - let sourceExpr = { type: 'Identifier', name: parts[0] }; - for (let i = 1; i < parts.length; i++) { - sourceExpr = { - type: 'MemberExpression', - object: sourceExpr, - property: { type: 'Identifier', name: parts[i] }, - computed: false - }; - } - - // let tempName = propertyPath.copy() - copyStatements.push({ - type: 'VariableDeclaration', - declarations: [{ - type: 'VariableDeclarator', - id: { type: 'Identifier', name: tempName }, - init: { - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: sourceExpr, - property: { type: 'Identifier', name: 'copy' }, - computed: false - }, - arguments: [] - } - }], - kind: 'let' - }); - } - // Apply reference replacement to all statements - functionBody.body.forEach(node => replaceReferences(node, tempVarMap)); - // Insert copy statements at the beginning - functionBody.body.unshift(...copyStatements); - // Add return statement with flat object using property paths as keys - const returnObj = { - type: 'ObjectExpression', - properties: Array.from(varsToReturn).map(varPath => ({ - type: 'Property', - key: { type: 'Literal', value: varPath }, - value: { type: 'Identifier', name: tempVarMap.get(varPath) }, - kind: 'init', - computed: false, - shorthand: false - })) - }; - functionBody.body.push({ - type: 'ReturnStatement', - argument: returnObj - }); - } - }; addCopyingAndReturn(thenFunction.body, assignedVars); addCopyingAndReturn(elseFunction.body, assignedVars); // Create a block variable to capture the return value @@ -734,8 +925,10 @@ const ASTCallbacks = { delete node.consequent; delete node.alternate; }, - UpdateExpression(node, _state, ancestors) { - if (ancestors.some(nodeIsUniform)) { return; } + UpdateExpression(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } // Transform ++var, var++, --var, var-- into assignment expressions let operator; @@ -766,11 +959,13 @@ const ASTCallbacks = { // Replace the update expression with the assignment expression Object.assign(node, assignmentExpr); delete node.prefix; - this.BinaryExpression(node.right, _state, [...ancestors, node]); - this.AssignmentExpression(node, _state, ancestors); + ASTCallbacks.BinaryExpression(node.right, state, [...ancestors, node]); + ASTCallbacks.AssignmentExpression(node, state, ancestors); }, - ForStatement(node, _state, ancestors) { - if (ancestors.some(nodeIsUniform)) { return; } + ForStatement(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } // Transform for statement into strandsFor() call // for (init; test; update) body -> strandsFor(initCb, conditionCb, updateCb, bodyCb, initialVars) @@ -822,7 +1017,7 @@ const ASTCallbacks = { // Replace loop variable references with the parameter if (node.init?.type === 'VariableDeclaration') { const loopVarName = node.init.declarations[0].id.name; - conditionBody = this.replaceIdentifierReferences(conditionBody, loopVarName, uniqueLoopVar); + conditionBody = replaceIdentifierReferences(conditionBody, loopVarName, uniqueLoopVar); } const conditionAst = { type: 'Program', body: [{ type: 'ExpressionStatement', expression: conditionBody }] }; conditionBody = conditionAst.body[0].expression; @@ -840,7 +1035,7 @@ const ASTCallbacks = { // Replace loop variable references with the parameter if (node.init?.type === 'VariableDeclaration') { const loopVarName = node.init.declarations[0].id.name; - updateExpr = this.replaceIdentifierReferences(updateExpr, loopVarName, uniqueLoopVar); + updateExpr = replaceIdentifierReferences(updateExpr, loopVarName, uniqueLoopVar); } const updateAst = { type: 'Program', body: [{ type: 'ExpressionStatement', expression: updateExpr }] }; updateExpr = updateAst.body[0].expression; @@ -879,7 +1074,7 @@ const ASTCallbacks = { // Replace loop variable references in the body if (node.init?.type === 'VariableDeclaration') { const loopVarName = node.init.declarations[0].id.name; - bodyBlock = this.replaceIdentifierReferences(bodyBlock, loopVarName, uniqueLoopVar); + bodyBlock = replaceIdentifierReferences(bodyBlock, loopVarName, uniqueLoopVar); } const bodyFunction = { @@ -934,73 +1129,8 @@ const ASTCallbacks = { }); if (assignedVars.size > 0) { - // Add copying, reference replacement, and return statements similar to if statements - const addCopyingAndReturn = (functionBody, varsToReturn) => { - if (functionBody.type === 'BlockStatement') { - const tempVarMap = new Map(); - const copyStatements = []; - - for (const varPath of varsToReturn) { - const parts = varPath.split('.'); - const tempName = `__copy_${parts.join('_')}_${blockVarCounter++}`; - tempVarMap.set(varPath, tempName); - - // Build the member expression for vars.propertyPath - // e.g., vars.inputs.color or vars.x - let sourceExpr = { type: 'Identifier', name: 'vars' }; - for (const part of parts) { - sourceExpr = { - type: 'MemberExpression', - object: sourceExpr, - property: { type: 'Identifier', name: part }, - computed: false - }; - } - copyStatements.push({ - type: 'VariableDeclaration', - declarations: [{ - type: 'VariableDeclarator', - id: { type: 'Identifier', name: tempName }, - init: { - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: sourceExpr, - property: { type: 'Identifier', name: 'copy' }, - computed: false - }, - arguments: [] - } - }], - kind: 'let' - }); - } - - functionBody.body.forEach(node => replaceReferences(node, tempVarMap)); - functionBody.body.unshift(...copyStatements); - - // Add return statement with flat object using property paths as keys - const returnObj = { - type: 'ObjectExpression', - properties: Array.from(varsToReturn).map(varPath => ({ - type: 'Property', - key: { type: 'Literal', value: varPath }, - value: { type: 'Identifier', name: tempVarMap.get(varPath) }, - kind: 'init', - computed: false, - shorthand: false - })) - }; - - functionBody.body.push({ - type: 'ReturnStatement', - argument: returnObj - }); - } - }; - - addCopyingAndReturn(bodyFunction.body, assignedVars); + addCopyingAndReturn(bodyFunction.body, assignedVars, 'vars'); // Create block variable and assignments similar to if statements const blockVar = `__block_${blockVarCounter++}`; @@ -1115,33 +1245,8 @@ const ASTCallbacks = { delete node.update; }, - // Helper method to replace identifier references in AST nodes - replaceIdentifierReferences(node, oldName, newName) { - if (!node || typeof node !== 'object') return node; - - const replaceInNode = (n) => { - if (!n || typeof n !== 'object') return n; - - if (n.type === 'Identifier' && n.name === oldName) { - return { ...n, name: newName }; - } - // Create a copy and recursively process properties - const newNode = { ...n }; - for (const key in n) { - if (n.hasOwnProperty(key) && key !== 'parent') { - if (Array.isArray(n[key])) { - newNode[key] = n[key].map(replaceInNode); - } else if (typeof n[key] === 'object') { - newNode[key] = replaceInNode(n[key]); - } - } - } - return newNode; - }; - return replaceInNode(node); - } } // Helper function to check if a function body contains return statements in control flow @@ -1476,22 +1581,31 @@ function transformFunctionSetCalls(functionNode) { } // Main transformation pass: find and transform functions with .set() calls in control flow -function transformSetCallsInControlFlow(ast) { +function transformSetCallsInControlFlow(ast, names) { const functionsToTransform = []; // Collect functions that have .set() calls in control flow const collectFunctions = { ArrowFunctionExpression(node, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) { + return; + } if (functionHasSetInControlFlow(node)) { functionsToTransform.push(node); } }, FunctionExpression(node, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) { + return; + } if (functionHasSetInControlFlow(node)) { functionsToTransform.push(node); } }, FunctionDeclaration(node, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) { + return; + } if (functionHasSetInControlFlow(node)) { functionsToTransform.push(node); } @@ -1507,12 +1621,15 @@ function transformSetCallsInControlFlow(ast) { } // Main transformation pass: find and transform helper functions with early returns -function transformHelperFunctionEarlyReturns(ast) { +function transformHelperFunctionEarlyReturns(ast, names) { const helperFunctionsToTransform = []; // Collect helper functions that need transformation const collectHelperFunctions = { VariableDeclarator(node, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) { + return; + } const init = node.init; if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) { if (functionHasEarlyReturns(init)) { @@ -1521,6 +1638,9 @@ function transformHelperFunctionEarlyReturns(ast) { } }, FunctionDeclaration(node, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, names))) { + return; + } if (functionHasEarlyReturns(node)) { helperFunctionsToTransform.push(node); } @@ -1540,67 +1660,106 @@ function transformHelperFunctionEarlyReturns(ast) { } } -export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) { - // Reset counters at the start of each transpilation - blockVarCounter = 0; - loopVarCounter = 0; - - const ast = parse(sourceString, { - ecmaVersion: 2021, - locations: srcLocations - }); - - // First pass: transform .set() calls in control flow to use intermediate variables - transformSetCallsInControlFlow(ast); +/** + * Transpiles a p5.strands callback into executable JavaScript by applying + * a multi-pass AST transformation pipeline. + * + * Pipeline stages: + * + * 1. Collect uniform callback names + * - Identifies functions passed into uniform() so they are excluded from transformation + * + * 2. transformSetCallsInControlFlow + * - Rewrites `.set()` calls inside control flow into intermediate variable assignments + * + * 3. Non-control-flow transformations + * - Applies ASTCallbacks to transform expressions, assignments, etc. + * - Skips IfStatement and ForStatement (handled later) + * + * 4. transformHelperFunctionEarlyReturns + * - Converts early returns in helper functions into a single return value pattern + * + * 5. Control flow transformation (post-order) + * - Transforms IfStatement β†’ __p5.strandsIf + * - Transforms ForStatement β†’ __p5.strandsFor + * - Handles variable propagation across branches/loops + * + * This staged approach ensures correct ordering and avoids transformation conflicts. + */ + +// Wraps each callback with a uniform context guard, eliminating the need +// to repeat the early-return check at the top of every handler. +function makeGuardedCallbacks(callbacks) { + const guarded = {}; + for (const [name, fn] of Object.entries(callbacks)) { + guarded[name] = (node, state, ancestors) => { + if (ancestors.some(a => + nodeIsUniform(a) || + nodeIsUniformCallbackFn(a, state.uniformCallbackNames) + )) return; + return fn(node, state, ancestors); + }; + } + return guarded; +} - // Second pass: transform everything except if/for statements using normal ancestor traversal - const nonControlFlowCallbacks = { ...ASTCallbacks }; +function runNonControlFlowPass(ast, uniformCallbackNames) { + const nonControlFlowCallbacks = ({ ...ASTCallbacks }); delete nonControlFlowCallbacks.IfStatement; delete nonControlFlowCallbacks.ForStatement; - ancestor(ast, nonControlFlowCallbacks, undefined, { varyings: {} }); - - // Third pass: transform helper functions with early returns to use __returnValue pattern - transformHelperFunctionEarlyReturns(ast); + ancestor(ast, nonControlFlowCallbacks, undefined, { varyings: {}, uniformCallbackNames }); +} - // Fourth pass: transform if/for statements in post-order using recursive traversal +function runControlFlowPass(ast, uniformCallbackNames) { const postOrderControlFlowTransform = { + CallExpression(node, state, c) { + if (nodeIsUniform(node)) { return; } + if (node.callee) c(node.callee, state); + for (const arg of node.arguments) c(arg, state); + }, + FunctionDeclaration(node, state, c) { + if (state.uniformCallbackNames?.has(node.id?.name)) return; + if (node.body) c(node.body, state); + }, + VariableDeclarator(node, state, c) { + if ( + state.uniformCallbackNames?.has(node.id?.name) && + (node.init?.type === 'FunctionExpression' || node.init?.type === 'ArrowFunctionExpression') + ) { return; } + if (node.init) c(node.init, state); + }, IfStatement(node, state, c) { state.inControlFlow++; - // First recursively process children if (node.test) c(node.test, state); if (node.consequent) c(node.consequent, state); if (node.alternate) c(node.alternate, state); - // Then apply the transformation to this node ASTCallbacks.IfStatement(node, state, []); state.inControlFlow--; }, ForStatement(node, state, c) { state.inControlFlow++; - // First recursively process children if (node.init) c(node.init, state); if (node.test) c(node.test, state); if (node.update) c(node.update, state); if (node.body) c(node.body, state); - // Then apply the transformation to this node ASTCallbacks.ForStatement(node, state, []); state.inControlFlow--; }, ReturnStatement(node, state, c) { if (!state.inControlFlow) return; - // Convert return statement to strandsEarlyReturn call node.type = 'ExpressionStatement'; node.expression = { type: 'CallExpression', - callee: { - type: 'Identifier', - name: '__p5.strandsEarlyReturn' - }, + callee: { type: 'Identifier', name: '__p5.strandsEarlyReturn' }, arguments: node.argument ? [node.argument] : [] }; delete node.argument; } }; - recursive(ast, { varyings: {}, inControlFlow: 0 }, postOrderControlFlowTransform); + recursive(ast, { varyings: {}, inControlFlow: 0, uniformCallbackNames }, postOrderControlFlowTransform); +} + +function buildStrandsCallback(p5, ast, scope) { const transpiledSource = escodegen.generate(ast); const scopeKeys = Object.keys(scope); const match = /\(?\s*(?:function)?\s*\w*\s*\(([^)]*)\)\s*(?:=>)?\s*{((?:.|\n)*)}\s*;?\s*\)?/ @@ -1620,15 +1779,11 @@ export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) { } const body = match[2]; try { - const internalStrandsCallback = new Function( - // Create a parameter called __p5, not just p5, because users of instance mode - // may pass in a variable called p5 as a scope variable. If we rely on a variable called - // p5, then the scope variable called p5 might accidentally override internal function - // calls to p5 static methods. - '__p5', - ...paramNames, - body, - ); + const internalStrandsCallback = new Function('__p5', ...paramNames, body); + // Create a parameter called __p5, not just p5, because users of instance mode + // may pass in a variable called p5 as a scope variable. If we rely on a variable called + // p5, then the scope variable called p5 might accidentally override internal function + // calls to p5 static methods. return () => internalStrandsCallback(p5, ...paramVals); } catch (e) { console.error(e); @@ -1637,3 +1792,31 @@ export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) { throw new Error('Error transpiling p5.strands callback!'); } } + + + +export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) { + blockVarCounter = 0; + loopVarCounter = 0; + + const ast = parse(sourceString, { ecmaVersion: 2021, locations: srcLocations }); + + throwIfLoopProtectionInserted(ast); + + // Pre-pass: collect names of functions passed by reference as uniform callbacks + const uniformCallbackNames = collectUniformCallbackNames(ast); + + // Pass 1: transform .set() calls in control flow to use intermediate variables + transformSetCallsInControlFlow(ast, uniformCallbackNames); + + // Pass 2: transform non-control-flow nodes (operators, varyings, uniforms, arrays) + runNonControlFlowPass(ast, uniformCallbackNames); + + // Pass 3: transform helper functions with early returns to use __returnValue pattern + transformHelperFunctionEarlyReturns(ast, uniformCallbackNames); + + // Pass 4: transform if/for statements post-order into strandsIf/strandsFor calls + runControlFlowPass(ast, uniformCallbackNames); + + return buildStrandsCallback(p5, ast, scope); +} diff --git a/src/type/lib/Typr.js b/src/type/lib/Typr.js index e81fcb58f1..ec7e94a5ec 100644 --- a/src/type/lib/Typr.js +++ b/src/type/lib/Typr.js @@ -323,7 +323,7 @@ Typr["B"] = { } return s; }, - _tdec: window["TextDecoder"] ? new window["TextDecoder"]() : null, + _tdec: globalThis["TextDecoder"] ? new globalThis["TextDecoder"]() : null, readUTF8: function (buff, p, l) { var tdec = Typr["B"]._tdec; if (tdec && p == 0 && l == buff.length) return tdec["decode"](buff); diff --git a/src/type/textCore.js b/src/type/textCore.js index 345267fa4c..b978fdee12 100644 --- a/src/type/textCore.js +++ b/src/type/textCore.js @@ -1,6 +1,5 @@ /** * @module Typography - * @requires core */ import { Renderer } from '../core/p5.Renderer'; @@ -1459,22 +1458,37 @@ function textCore(p5, fn) { Renderer.prototype.textAlign = function (h, v) { - // the setter - if (typeof h !== 'undefined') { + if (arguments.length === 0) { // the getter + return { + horizontal: this.states.textAlign, + vertical: this.states.textBaseline + }; + } + + // allow an object with horizontal and vertical properties + if (typeof h === 'object' && h !== null) { + if (h.hasOwnProperty('vertical')) { + v = h.vertical; + } + if (h.hasOwnProperty('horizontal')) { + h = h.horizontal; + } + } + + // horizontal value as separate argument + if (typeof h === 'string' || h instanceof String) { this.states.setValue('textAlign', h); - if (typeof v !== 'undefined') { - if (v === fn.CENTER) { - v = textCoreConstants._CTX_MIDDLE; - } - this.states.setValue('textBaseline', v); + } + + // vertical value as separate argument + if (typeof v === 'string' || v instanceof String) { + if (v === fn.CENTER) { + v = textCoreConstants._CTX_MIDDLE; } - return this._applyTextProperties(); + this.states.setValue('textBaseline', v); } - // the getter - return { - horizontal: this.states.textAlign, - vertical: this.states.textBaseline - }; + + return this._applyTextProperties(); }; Renderer.prototype._currentTextFont = function () { @@ -1573,13 +1587,6 @@ function textCore(p5, fn) { if (typeof weight === 'number') { this.states.setValue('fontWeight', weight); this._applyTextProperties(); - - // Safari works without weight set in the canvas style attribute, and actually - // has buggy behavior if it is present, using the wrong weight when drawing - // multiple times with different weights - if (!p5.prototype._isSafari()) { - this._setCanvasStyleProperty('font-variation-settings', `"wght" ${weight}`); - } return; } // the getter diff --git a/src/utilities/conversion.js b/src/utilities/conversion.js index 7ebe26ba8f..5727735156 100644 --- a/src/utilities/conversion.js +++ b/src/utilities/conversion.js @@ -2,7 +2,6 @@ * @module Data * @submodule Conversion * @for p5 - * @requires core */ function conversion(p5, fn){ diff --git a/src/utilities/time_date.js b/src/utilities/time_date.js index ab240ee058..5b0b4c154b 100644 --- a/src/utilities/time_date.js +++ b/src/utilities/time_date.js @@ -2,7 +2,6 @@ * @module IO * @submodule Time & Date * @for p5 - * @requires core */ function timeDate(p5, fn){ @@ -109,10 +108,8 @@ function timeDate(p5, fn){ * sketch includes asynchronous loading using `async`/`await`, then * `millis()` begins tracking time as soon as the asynchronous code * starts running. - * @method millis - * @return {Number} number of milliseconds since starting the sketch. * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -133,8 +130,9 @@ function timeDate(p5, fn){ * `The text 'Startup time: ${round(ms, 2)} ms' written in black on a gray background.` * ); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -155,8 +153,9 @@ function timeDate(p5, fn){ * // Display how long the sketch has run. * text(`Running time: ${nf(s, 1, 1)} sec`, 5, 50, 90); * } + * ``` * - * @example + * ```js example * function setup() { * createCanvas(100, 100); * @@ -175,8 +174,9 @@ function timeDate(p5, fn){ * // Draw the circle. * circle(x, 50, 30); * } + * ``` * - * @example + * ```js example * async function setup() { * // Load the GeoJSON. * await loadJSON('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson'); @@ -199,6 +199,40 @@ function timeDate(p5, fn){ * `The text "It took ${round(ms, 2)} ms to load the data" written in black on a gray background.` * ); * } + * ``` + * + * `millis()` can also be used in shaders with p5.strands. The following example + * uses `millis()` to create time-based color transitions on a shape. + * + * ```js example + * let myShader; + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * myShader = buildColorShader(shaderCallback); + * describe('A sphere whose color shifts over time.'); + * } + * + * function shaderCallback() { + * let t = millis() * 0.001; + * let value = 0.5 + 0.5 * sin(t); + * let skyBlue = [0.2, 0.6, 0.8, 1]; + * let magenta = [0.8, 0.2, 0.6, 1]; + * finalColor.begin(); + * finalColor.set(mix(skyBlue, magenta, value)); + * finalColor.end(); + * } + * + * function draw() { + * background(220); + * shader(myShader); + * noStroke(); + * sphere(30); + * } + * ``` + * + * @method millis + * @return {Number} number of milliseconds since starting the sketch. */ fn.millis = function() { if (this._millisStart === -1) { diff --git a/src/utilities/utility_functions.js b/src/utilities/utility_functions.js index 4929522642..11964db774 100644 --- a/src/utilities/utility_functions.js +++ b/src/utilities/utility_functions.js @@ -2,7 +2,6 @@ * @module Data * @submodule Utility Functions * @for p5 - * @requires core */ function utilityFunctions(p5, fn){ diff --git a/src/webgl/3d_primitives.js b/src/webgl/3d_primitives.js index cd6cccc893..0d10bfdf4b 100644 --- a/src/webgl/3d_primitives.js +++ b/src/webgl/3d_primitives.js @@ -2,8 +2,6 @@ * @module Shape * @submodule 3D Primitives * @for p5 - * @requires core - * @requires p5.Geometry */ import * as constants from '../core/constants'; @@ -1794,33 +1792,38 @@ function primitives3D(p5, fn){ const prevMode = this.states.textureMode; this.states.setValue('textureMode', constants.NORMAL); const prevOrder = this.bezierOrder(); - this.bezierOrder(2); + this.bezierOrder(3); this.beginShape(); const addUVs = (x, y) => [x, y, 0, (x - x1)/width, (y - y1)/height]; + const rr = 0.5523; // kappa: 4*(sqrt(2)-1)/3, handle ratio for cubic bezier circle approximation if (tr !== 0) { this.vertex(...addUVs(x2 - tr, y1)); - this.bezierVertex(...addUVs(x2, y1)); + this.bezierVertex(...addUVs(x2 - tr + tr * rr, y1)); + this.bezierVertex(...addUVs(x2, y1 + tr - tr * rr)); this.bezierVertex(...addUVs(x2, y1 + tr)); } else { this.vertex(...addUVs(x2, y1)); } if (br !== 0) { this.vertex(...addUVs(x2, y2 - br)); - this.bezierVertex(...addUVs(x2, y2)); + this.bezierVertex(...addUVs(x2, y2 - br + br * rr)); + this.bezierVertex(...addUVs(x2 - br + rr * br, y2)); this.bezierVertex(...addUVs(x2 - br, y2)); } else { this.vertex(...addUVs(x2, y2)); } if (bl !== 0) { this.vertex(...addUVs(x1 + bl, y2)); - this.bezierVertex(...addUVs(x1, y2)); + this.bezierVertex(...addUVs(x1 + bl - bl * rr, y2)); + this.bezierVertex(...addUVs(x1, y2 - bl + bl * rr)); this.bezierVertex(...addUVs(x1, y2 - bl)); } else { this.vertex(...addUVs(x1, y2)); } if (tl !== 0) { this.vertex(...addUVs(x1, y1 + tl)); - this.bezierVertex(...addUVs(x1, y1)); + this.bezierVertex(...addUVs(x1, y1 + tl - tl * rr)); + this.bezierVertex(...addUVs(x1 + tl - tl * rr, y1)); this.bezierVertex(...addUVs(x1 + tl, y1)); } else { this.vertex(...addUVs(x1, y1)); diff --git a/src/webgl/GeometryBuilder.js b/src/webgl/GeometryBuilder.js index b59548bf67..777feb838e 100644 --- a/src/webgl/GeometryBuilder.js +++ b/src/webgl/GeometryBuilder.js @@ -64,11 +64,15 @@ class GeometryBuilder { } let startIdx = this.geometry.vertices.length; - this.geometry.vertices.push(...this.transformVertices(input.vertices)); - this.geometry.vertexNormals.push( - ...this.transformNormals(input.vertexNormals) - ); - this.geometry.uvs.push(...input.uvs); + for (const v of this.transformVertices(input.vertices)) { + this.geometry.vertices.push(v); + } + for (const vn of this.transformNormals(input.vertexNormals)) { + this.geometry.vertexNormals.push(vn); + } + for (const val of input.uvs) { + this.geometry.uvs.push(val); + } const inputUserVertexProps = input.userVertexProperties; const builtUserVertexProps = this.geometry.userVertexProperties; @@ -103,15 +107,17 @@ class GeometryBuilder { ); } if (this.renderer.states.strokeColor) { - this.geometry.edges.push( - ...input.edges.map(edge => edge.map(idx => idx + startIdx)) - ); + for (const edge of input.edges.map(edge => edge.map(idx => idx + startIdx))) { + this.geometry.edges.push(edge); + } } const vertexColors = [...input.vertexColors]; while (vertexColors.length < input.vertices.length * 4) { vertexColors.push(...this.renderer.states.curFillColor); } - this.geometry.vertexColors.push(...vertexColors); + for (const c of vertexColors) { + this.geometry.vertexColors.push(c); + } } /** diff --git a/src/webgl/ShapeBuilder.js b/src/webgl/ShapeBuilder.js index 124fa62bfa..9fbcf5955c 100644 --- a/src/webgl/ShapeBuilder.js +++ b/src/webgl/ShapeBuilder.js @@ -45,6 +45,10 @@ export class ShapeBuilder { this.bufferStrides = { ...INITIAL_BUFFER_STRIDES }; } + friendlyErrorsDisabled() { + return false; + } + constructFromContours(shape, contours) { if (this._useUserVertexProperties){ this._resetUserVertexProperties(); @@ -148,6 +152,27 @@ export class ShapeBuilder { } if (this.shapeMode === constants.PATH) { + const vertexCount = this.geometry.vertices.length; + const MAX_SAFE_TESSELLATION_VERTICES = 50000; + + if ( + vertexCount > MAX_SAFE_TESSELLATION_VERTICES && + !this.friendlyErrorsDisabled() && + !this.renderer._largeTessellationAcknowledged + ) { + const proceed = window.confirm( + '🌸 p5.js says:\n\n' + + `This shape has ${vertexCount} vertices. Tessellating shapes with this ` + + 'many vertices can be very slow and may cause your browser to become ' + + 'unresponsive.\n\n' + + 'Do you want to continue tessellating this shape?' + ); + if (!proceed) { + return; + } + this.renderer._largeTessellationAcknowledged = true; + } + this.isProcessingVertices = true; this._tesselateShape(); this.isProcessingVertices = false; diff --git a/src/webgl/interaction.js b/src/webgl/interaction.js index ef5beb665a..669ff75553 100644 --- a/src/webgl/interaction.js +++ b/src/webgl/interaction.js @@ -2,7 +2,6 @@ * @module 3D * @submodule Interaction * @for p5 - * @requires core */ import * as constants from '../core/constants'; @@ -370,8 +369,11 @@ function interaction(p5, fn){ // accelerate rotate velocity this._renderer.rotateVelocity.add( deltaTheta * rotateAccelerationFactor, - deltaPhi * rotateAccelerationFactor + deltaPhi * rotateAccelerationFactor, + 0 ); + + //console.log("added"); } if (this._renderer.rotateVelocity.magSq() > 0.000001) { // if freeRotation is true, the camera always rotates freely in the direction the pointer moves @@ -390,8 +392,10 @@ function interaction(p5, fn){ } // damping this._renderer.rotateVelocity.mult(damping); + //console.log("multiplied", damping, this._renderer.rotateVelocity); + } else { - this._renderer.rotateVelocity.set(0, 0); + this._renderer.rotateVelocity.set(0, 0, 0); } // move process diff --git a/src/webgl/light.js b/src/webgl/light.js index 72938bc291..1088c909e1 100644 --- a/src/webgl/light.js +++ b/src/webgl/light.js @@ -2,7 +2,6 @@ * @module 3D * @submodule Lights * @for p5 - * @requires core */ import { Renderer3D } from '../core/p5.Renderer3D'; @@ -499,6 +498,11 @@ function light(p5, fn){ * sphere(30); * } * + * function doubleClicked() { + * isLit = !isLit; + * return false; + * } + * * @example * // Click and drag the mouse to view the scene from different angles. * diff --git a/src/webgl/loading.js b/src/webgl/loading.js index 23fe61b123..5edd0d0791 100755 --- a/src/webgl/loading.js +++ b/src/webgl/loading.js @@ -2,8 +2,6 @@ * @module Shape * @submodule 3D Models * @for p5 - * @requires core - * @requires p5.Geometry */ import { Geometry } from './p5.Geometry'; @@ -615,7 +613,7 @@ function loading(p5, fn){ model.uvs.push(loadedVerts.vt.at(vertParts[1]) ? loadedVerts.vt.at(vertParts[1]).slice() : [0, 0]); model.vertexNormals.push(loadedVerts.vn.at(vertParts[2]) ? - loadedVerts.vn.at(vertParts[2]).copy() : new Vector()); + loadedVerts.vn.at(vertParts[2]).copy() : new Vector(0, 0, 0)); usedVerts[vertString][currentMaterial] = vertIndex; face.push(vertIndex); @@ -631,6 +629,7 @@ function loading(p5, fn){ model.vertexColors.push(1); } else { hasColorlessVertices = true; + model.vertexColors.push(-1, -1, -1, -1); } } else { face.push(usedVerts[vertString][currentMaterial]); @@ -652,9 +651,8 @@ function loading(p5, fn){ if (model.vertexNormals.length === 0) { model.computeNormals(); } - if (hasColoredVertices === hasColorlessVertices) { - // If both are true or both are false, throw an error because the model is inconsistent - throw new Error('Model coloring is inconsistent. Either all vertices should have colors or none should.'); + if (!hasColoredVertices) { + model.vertexColors = []; } return model; @@ -969,7 +967,7 @@ function loading(p5, fn){ /** * Draws a p5.Geometry object to the canvas. * - * The parameter, `model`, is the + * The first parameter, `model`, is the * p5.Geometry object to draw. * p5.Geometry objects can be built with * buildGeometry(). They can also be loaded from @@ -977,11 +975,7 @@ function loading(p5, fn){ * * Note: `model()` can only be used in WebGL mode. * - * @method model - * @param {p5.Geometry} model 3D shape to be drawn. - * - * @param {Number} [count=1] number of instances to draw. - * @example + * ```js example * // Click and drag the mouse to view the scene from different angles. * * let shape; @@ -1009,8 +1003,9 @@ function loading(p5, fn){ * function createShape() { * cone(); * } + * ``` * - * @example + * ```js example * // Click and drag the mouse to view the scene from different angles. * * let shape; @@ -1056,8 +1051,9 @@ function loading(p5, fn){ * cylinder(3, 20); * pop(); * } + * ``` * - * @example + * ```js example * // Click and drag the mouse to view the scene from different angles. * * let shape; @@ -1079,6 +1075,51 @@ function loading(p5, fn){ * // Draw the shape. * model(shape); * } + * ``` + * + * Multiple instances can be drawn at once with `model(geometry, count)`. On its own, + * all the instances get drawn to the same spot, but you can use + * `instanceID()` inside of a shader to handle each instance. + * At large counts, this often runs faster than using a `for` loop. + * + * ```js example + * let instancesShader; + * let instance; + * let count = 5; + * + * function drawInstance() { + * sphere(15); + * } + * + * function setup() { + * createCanvas(200, 200, WEBGL); + * instance = buildGeometry(drawInstance); + * instancesShader = buildMaterialShader(drawSpaced); + * } + * + * function drawSpaced() { + * worldInputs.begin(); + * // Spread spheres evenly across the canvas based on their index + * let spacing = width / count; + * worldInputs.position.x += + * (instanceID() - (count - 1) / 2) * spacing; + * worldInputs.end(); + * } + * + * function draw() { + * background(220); + * lights(); + * noStroke(); + * fill('red'); + * shader(instancesShader); + * model(instance, count); + * } + * ``` + * + * @method model + * @param {p5.Geometry} model 3D shape to be drawn. + * + * @param {Number} [count=1] number of instances to draw. */ fn.model = function (model, count = 1) { this._assert3d('model'); diff --git a/src/webgl/material.js b/src/webgl/material.js index 2e1bc0c7e1..000a12fb7c 100644 --- a/src/webgl/material.js +++ b/src/webgl/material.js @@ -2,7 +2,6 @@ * @module 3D * @submodule Material * @for p5 - * @requires core */ import * as constants from "../core/constants"; @@ -500,6 +499,7 @@ function material(p5, fn) { * `loadFilterShader('myShader.js', onLoaded)`. * * @method loadFilterShader + * @beta * @submodule p5.strands * @param {String} filename path to a p5.strands JavaScript file or a GLSL fragment shader file * @param {Function} [successCallback] callback to be called once the shader is @@ -2652,6 +2652,8 @@ function material(p5, fn) { * * Note: `textureMode()` can only be used in WebGL mode. * + * Calling `textureMode()` with no arguments returns the current texture mode. + * * @method textureMode * @param {(IMAGE|NORMAL)} mode either IMAGE or NORMAL. * @@ -2714,7 +2716,14 @@ function material(p5, fn) { * endShape(); * } */ + /** + * @method textureMode + * @return {(IMAGE|NORMAL)} The current texture mode, either IMAGE or NORMAL. + */ fn.textureMode = function (mode) { + if (typeof mode === 'undefined') { // getter + return this._renderer.states.textureMode; + } if (mode !== constants.IMAGE && mode !== constants.NORMAL) { console.warn( `You tried to set ${mode} textureMode only supports IMAGE & NORMAL `, @@ -2822,6 +2831,9 @@ function material(p5, fn) { * * Note: `textureWrap()` can only be used in WebGL mode. * + * Calling `textureWrap()` with no arguments returns an object with the current + * mode for x and y directions, as in `{ wrapX: CLAMP, wrapY: REPEAT }`. + * * @method textureWrap * @param {(CLAMP|REPEAT|MIRROR)} wrapX either CLAMP, REPEAT, or MIRROR * @param {(CLAMP|REPEAT|MIRROR)} [wrapY=wrapX] either CLAMP, REPEAT, or MIRROR @@ -2977,13 +2989,31 @@ function material(p5, fn) { * endShape(); * } */ + /** + * @method textureWrap + * @return {{x: (CLAMP|REPEAT|MIRROR), y: (CLAMP|REPEAT|MIRROR)}} The current texture wrapping for x and y. + */ fn.textureWrap = function (wrapX, wrapY = wrapX) { - this._renderer.states.setValue("textureWrapX", wrapX); - this._renderer.states.setValue("textureWrapY", wrapY); + if (typeof wrapX === 'undefined') { // getter + return { + x: this._renderer.states.textureWrapX, + y: this._renderer.states.textureWrapY + }; + } + // accept what is returned from the getter + if (wrapX.hasOwnProperty('x') && wrapX.hasOwnProperty('y')) { + wrapX = wrapX.x; + wrapY = wrapX.y; + } + this._renderer.states.setValue('textureWrapX', wrapX); + this._renderer.states.setValue('textureWrapY', wrapY); - for (const texture of this._renderer.textures.values()) { - texture.setWrapMode(wrapX, wrapY); + if (this._renderer.textures) { + for (const texture of this._renderer.textures.values()) { + texture.setWrapMode(wrapX, wrapY); + } } + return this; }; /** diff --git a/src/webgl/p5.Camera.js b/src/webgl/p5.Camera.js index 5dcca97a20..38929118fe 100644 --- a/src/webgl/p5.Camera.js +++ b/src/webgl/p5.Camera.js @@ -1,7 +1,6 @@ /** * @module 3D * @submodule Camera - * @requires core */ import { Matrix } from '../math/p5.Matrix'; @@ -1601,10 +1600,10 @@ class Camera { const up1 = rotMat1.row(1); // prepare new vectors. - const newFront = new Vector(); - const newUp = new Vector(); - const newEye = new Vector(); - const newCenter = new Vector(); + const newFront = new Vector(0, 0, 0); + const newUp = new Vector(0, 0, 0); + const newEye = new Vector(0, 0, 0); + const newCenter = new Vector(0, 0, 0); // Create the inverse matrix of mat0 by transposing mat0, // and multiply it to mat1 from the right. diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index 29ef58bc3f..fc04f83792 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -1,6 +1,5 @@ /** * @module Rendering - * @requires constants */ import * as constants from '../core/constants'; diff --git a/src/webgl/p5.Geometry.js b/src/webgl/p5.Geometry.js index dad4c860f4..67658cfb49 100644 --- a/src/webgl/p5.Geometry.js +++ b/src/webgl/p5.Geometry.js @@ -2,8 +2,6 @@ * @module Shape * @submodule 3D Primitives * @for p5 - * @requires core - * @requires p5.Geometry */ //some of the functions are adjusted from Three.js(http://threejs.org) @@ -330,7 +328,7 @@ class Geometry { * let saveBtn; * function setup() { * createCanvas(200, 200, WEBGL); - * myModel = buildGeometry(function()) { + * myModel = buildGeometry(function() { * for (let i = 0; i < 5; i++) { * push(); * translate( @@ -1205,7 +1203,7 @@ class Geometry { // initialize the vertexNormals array with empty vectors vertexNormals.length = 0; for (iv = 0; iv < vertices.length; ++iv) { - vertexNormals.push(new Vector()); + vertexNormals.push(new Vector(0, 0, 0)); } // loop through all the faces adding its normal to the normal diff --git a/src/webgl/p5.RendererGL.js b/src/webgl/p5.RendererGL.js index a17bd29334..78de4092f8 100644 --- a/src/webgl/p5.RendererGL.js +++ b/src/webgl/p5.RendererGL.js @@ -19,7 +19,6 @@ import { Image } from '../image/p5.Image'; import { glslBackend } from './strands_glslBackend'; import { TypeInfoFromGLSLName } from '../strands/ir_types.js'; import { getShaderHookTypes } from './shaderHookUtils'; -import noiseGLSL from './shaders/functions/noise3DGLSL.glsl'; import filterBaseVert from "./shaders/filters/base.vert"; import lightingShader from "./shaders/lighting.glsl"; @@ -530,7 +529,7 @@ class RendererGL extends Renderer3D { /** * Loads the pixels data for this canvas into the pixels[] attribute. - * Note that updatePixels() and set() do not work. + * Note that set() does not work. * Any pixel manipulation must be done directly to the pixels[] array. * * @private @@ -723,7 +722,7 @@ class RendererGL extends Renderer3D { color.a = components.opacity; return color; }`, - "vec4 getFinalColor": "(vec4 color) { return color; }", + "vec4 getFinalColor": "(vec4 color, vec2 texCoord) { return color; }", "void afterFragment": "() {}", }, } @@ -760,7 +759,7 @@ class RendererGL extends Renderer3D { }, fragment: { "void beforeFragment": "() {}", - "vec4 getFinalColor": "(vec4 color) { return color; }", + "vec4 getFinalColor": "(vec4 color, vec2 texCoord) { return color; }", "void afterFragment": "() {}", }, } @@ -788,7 +787,7 @@ class RendererGL extends Renderer3D { }, fragment: { "void beforeFragment": "() {}", - "vec4 getFinalColor": "(vec4 color) { return color; }", + "vec4 getFinalColor": "(vec4 color, vec2 texCoord) { return color; }", "void afterFragment": "() {}", }, } @@ -820,7 +819,7 @@ class RendererGL extends Renderer3D { fragment: { "void beforeFragment": "() {}", "Inputs getPixelInputs": "(Inputs inputs) { return inputs; }", - "vec4 getFinalColor": "(vec4 color) { return color; }", + "vec4 getFinalColor": "(vec4 color, vec2 texCoord) { return color; }", "bool shouldDiscard": "(bool outside) { return outside; }", "void afterFragment": "() {}", }, @@ -1129,6 +1128,7 @@ class RendererGL extends Renderer3D { ); } + shader._compiled = true; shader._glProgram = program; shader._vertShader = vertShader; shader._fragShader = fragShader; @@ -1904,10 +1904,6 @@ class RendererGL extends Renderer3D { } } - getNoiseShaderSnippet() { - return noiseGLSL; - } - } function rendererGL(p5, fn) { diff --git a/src/webgl/p5.Shader.js b/src/webgl/p5.Shader.js index 6cad5b6cfe..7818e45edd 100644 --- a/src/webgl/p5.Shader.js +++ b/src/webgl/p5.Shader.js @@ -3,18 +3,38 @@ * @module 3D * @submodule Material * @for p5 - * @requires core */ const TypedArray = Object.getPrototypeOf(Uint8Array); class Shader { constructor(renderer, vertSrc, fragSrc, options = {}) { this._renderer = renderer; - this._vertSrc = vertSrc; - this._fragSrc = fragSrc; + + // Detect compute shader: first arg is STRING and second is undefined OR an options object + if ( + typeof vertSrc === 'string' && ( + fragSrc === undefined || (typeof fragSrc === 'object' && !Array.isArray(fragSrc)) + ) + ) { + // Compute shader + this.shaderType = 'compute'; + this._computeSrc = vertSrc; + this._vertSrc = null; + this._fragSrc = null; + // If fragSrc is an options object, use it + if (typeof fragSrc === 'object') { + options = fragSrc; + } + } else { + // Render shader - shaderType will be set later during binding ('fill', 'stroke', etc.) + this._vertSrc = vertSrc; + this._fragSrc = fragSrc; + this._computeSrc = null; + } + this._vertShader = -1; this._fragShader = -1; - this._glProgram = 0; + this._compiled = false; this._loadedAttributes = false; this.attributes = {}; this._loadedUniforms = false; @@ -28,18 +48,25 @@ class Shader { // Stores uniforms + default values. uniforms: options.uniforms || {}, + // Compute shader storage uniforms + default values + storageUniforms: options.storageUniforms || {}, + // Stores custom uniform + helper declarations as a string. declarations: options.declarations, // Stores an array of variable names + types passed between the vertex and fragment shader varyingVariables: options.varyingVariables || [], + // Stores instanceID varying info for forwarding to the fragment shader + instanceIDVarying: options.instanceIDVarying || null, + // Stores helper functions to prepend to shaders. helpers: options.helpers || {}, // Stores the hook implementations vertex: options.vertex || {}, fragment: options.fragment || {}, + compute: options.compute || {}, hookAliases: options.hookAliases || {}, @@ -48,7 +75,8 @@ class Shader { // yourShader.modify(...). modified: { vertex: (options.modified && options.modified.vertex) || {}, - fragment: (options.modified && options.modified.fragment) || {} + fragment: (options.modified && options.modified.fragment) || {}, + compute: (options.modified && options.modified.compute) || {}, } }; } @@ -80,13 +108,20 @@ class Shader { } vertSrc() { + if (this.shaderType === 'compute') return null; return this.shaderSrc(this._vertSrc, 'vertex'); } fragSrc() { + if (this.shaderType === 'compute') return null; return this.shaderSrc(this._fragSrc, 'fragment'); } + computeSrc() { + if (this.shaderType !== 'compute') return null; + return this.shaderSrc(this._computeSrc, 'compute'); + } + /** * Logs the hooks available in this shader, and their current implementation. * @@ -136,29 +171,40 @@ class Shader { * color.a = components.opacity; * return color; * } - * vec4 getFinalColor(vec4 color) { return color; } + * vec4 getFinalColor(vec4 color, vec2 texCoord) { return color; } * void afterFragment() {} * ``` * * @beta */ inspectHooks() { - console.log('==== Vertex shader hooks: ===='); - for (const key in this.hooks.vertex) { - console.log( - (this.hooks.modified.vertex[key] ? '[MODIFIED] ' : '') + - key + - this.hooks.vertex[key] - ); - } - console.log(''); - console.log('==== Fragment shader hooks: ===='); - for (const key in this.hooks.fragment) { - console.log( - (this.hooks.modified.fragment[key] ? '[MODIFIED] ' : '') + - key + - this.hooks.fragment[key] - ); + if (this.shaderType === 'compute') { + console.log('==== Compute shader hooks: ===='); + for (const key in this.hooks.compute) { + console.log( + (this.hooks.modified.compute[key] ? '[MODIFIED] ' : '') + + key + + this.hooks.compute[key] + ); + } + } else { + console.log('==== Vertex shader hooks: ===='); + for (const key in this.hooks.vertex) { + console.log( + (this.hooks.modified.vertex[key] ? '[MODIFIED] ' : '') + + key + + this.hooks.vertex[key] + ); + } + console.log(''); + console.log('==== Fragment shader hooks: ===='); + for (const key in this.hooks.fragment) { + console.log( + (this.hooks.modified.fragment[key] ? '[MODIFIED] ' : '') + + key + + this.hooks.fragment[key] + ); + } } console.log(''); console.log('==== Helper functions: ===='); @@ -209,15 +255,15 @@ class Shader { * type of the data, such as `uniformFloat` for a number or `uniformVector2` for a two-component vector. * They take in a function that returns the data for the variable. You can then reference these * variables in your hooks, and their values will update every time you apply - * the shader with the result of your function. - * + * the shader with the result of your function. + * * Move the mouse over this sketch to increase the moveCounter which will be passed to the shader as a uniform. * * ```js example * let myShader; * //count of frames in which mouse has been moved * let moveCounter = 0; - * + * * function setup() { * createCanvas(200, 200, WEBGL); * myShader = baseMaterialShader().modify(() => { @@ -370,28 +416,37 @@ class Shader { const newHooks = { vertex: {}, fragment: {}, + compute: {}, helpers: {} }; for (const key in hooks) { if (key === 'declarations') continue; if (key === 'uniforms') continue; + if (key === 'storageUniforms') continue; if (key === 'varyingVariables') continue; + if (key === 'instanceIDVarying') continue; if (key === 'vertexDeclarations') { newHooks.vertex.declarations = (newHooks.vertex.declarations || '') + '\n' + hooks[key]; } else if (key === 'fragmentDeclarations') { newHooks.fragment.declarations = (newHooks.fragment.declarations || '') + '\n' + hooks[key]; + } else if (key === 'computeDeclarations') { + newHooks.compute.declarations = + (newHooks.compute.declarations || '') + '\n' + hooks[key]; } else if (this.hooks.vertex[key]) { newHooks.vertex[key] = hooks[key]; } else if (this.hooks.fragment[key]) { newHooks.fragment[key] = hooks[key]; + } else if (this.hooks.compute[key]) { + newHooks.compute[key] = hooks[key]; } else { newHooks.helpers[key] = hooks[key]; } } const modifiedVertex = Object.assign({}, this.hooks.modified.vertex); const modifiedFragment = Object.assign({}, this.hooks.modified.fragment); + const modifiedCompute = Object.assign({}, this.hooks.modified.compute); for (const key in newHooks.vertex || {}) { if (key === 'declarations') continue; modifiedVertex[key] = true; @@ -400,21 +455,37 @@ class Shader { if (key === 'declarations') continue; modifiedFragment[key] = true; } + for (const key in newHooks.compute || {}) { + if (key === 'declarations') continue; + modifiedCompute[key] = true; + } - return new Shader(this._renderer, this._vertSrc, this._fragSrc, { + const args = [this._renderer]; + if (this.shaderType === 'compute') { + args.push(this._computeSrc); + } else { + args.push(this._vertSrc, this._fragSrc); + } + args.push({ declarations: (this.hooks.declarations || '') + '\n' + (hooks.declarations || ''), uniforms: Object.assign({}, this.hooks.uniforms, hooks.uniforms || {}), + storageUniforms: Object.assign({}, this.hooks.storageUniforms, hooks.storageUniforms || {}), varyingVariables: (hooks.varyingVariables || []).concat(this.hooks.varyingVariables || []), + instanceIDVarying: hooks.instanceIDVarying || this.hooks.instanceIDVarying || null, fragment: Object.assign({}, this.hooks.fragment, newHooks.fragment || {}), vertex: Object.assign({}, this.hooks.vertex, newHooks.vertex || {}), + compute: Object.assign({}, this.hooks.compute, newHooks.compute || {}), helpers: Object.assign({}, this.hooks.helpers, newHooks.helpers || {}), hookAliases: Object.assign({}, this.hooks.hookAliases, newHooks.hookAliases || {}), modified: { vertex: modifiedVertex, - fragment: modifiedFragment + fragment: modifiedFragment, + compute: modifiedCompute, } }); + + return new Shader(...args); } /** @@ -436,7 +507,9 @@ class Shader { ); } - this._loadAttributes(); + if (this.shaderType !== 'compute') { + this._loadAttributes(); + } this._loadUniforms(); this._renderer._finalizeShader(this); @@ -464,6 +537,13 @@ class Shader { this.setUniform(name, value); } } + for (const name in this.hooks.storageUniforms) { + const initializer = this.hooks.storageUniforms[name]; + const value = initializer instanceof Function ? initializer() : initializer; + if (value !== undefined && value !== null) { + this.setUniform(name, value); + } + } } /** @@ -639,11 +719,14 @@ class Shader { * } */ copyToContext(context) { - const shader = new Shader( - context._renderer, - this._vertSrc, - this._fragSrc - ); + const args = [context._renderer]; + if (this.shaderType === 'compute') { + args.push(this._computeSrc); + } else { + args.push(this._vertSrc, this._fragSrc); + } + args.push(this.hooks); + const shader = new Shader(...args); shader.ensureCompiledOnContext(context._renderer); return shader; } @@ -652,11 +735,11 @@ class Shader { * @private */ ensureCompiledOnContext(context) { - if (this._glProgram !== 0 && this._renderer !== context) { + if (this._compiled && this._renderer !== context) { throw new Error( 'The shader being run is attached to a different context. Do you need to copy it to this context first with .copyToContext()?' ); - } else if (this._glProgram === 0) { + } else if (!this._compiled) { this._renderer = context?._renderer?.filterRenderer?._renderer || context; this.init(); } @@ -809,7 +892,7 @@ class Shader { * @chainable * @param {String} uniformName name of the uniform. Must match the name * used in the vertex and fragment shaders. - * @param {Boolean|Number|Number[]|p5.Image|p5.Graphics|p5.MediaElement|p5.Texture} + * @param {Boolean|p5.Vector|p5.Color|Number|Number[]|p5.Image|p5.Graphics|p5.MediaElement|p5.Texture|p5.StorageBuffer} * data value to assign to the uniform. Must match the uniform’s data type. * * @example @@ -1020,6 +1103,16 @@ class Shader { return; } + // In p5.strands-related code, where some of the code may be in + // p5.webgpu.js instead of the main p5.js build, we generally use + // duck typing instead of instanceof to avoid accidentally importing + // and comparing against a separate copy of p5 classes + if (data?.isVector) { + data = data.values.length !== data.dimensions ? data.values.slice(0, data.dimensions) : data.values; + } else if (data?.isColor) { + data = data._getRGBA([1, 1, 1, 1]); + } + if (uniform.isArray) { if ( uniform._cachedData && diff --git a/src/webgl/p5.Texture.js b/src/webgl/p5.Texture.js index 7441ae5139..3c12e6ed69 100644 --- a/src/webgl/p5.Texture.js +++ b/src/webgl/p5.Texture.js @@ -3,7 +3,6 @@ * @module 3D * @submodule Material * @for p5 - * @requires core */ import * as constants from '../core/constants'; diff --git a/src/webgl/shaders/basic.frag b/src/webgl/shaders/basic.frag index 1406964ca9..b7db52b1ad 100644 --- a/src/webgl/shaders/basic.frag +++ b/src/webgl/shaders/basic.frag @@ -1,7 +1,8 @@ IN vec4 vColor; +IN highp vec2 vVertTexCoord; void main(void) { HOOK_beforeFragment(); - OUT_COLOR = HOOK_getFinalColor(vColor); + OUT_COLOR = HOOK_getFinalColor(vColor, vVertTexCoord); OUT_COLOR.rgb *= OUT_COLOR.a; // Premultiply alpha before rendering HOOK_afterFragment(); -} +} \ No newline at end of file diff --git a/src/webgl/shaders/functions/randomGLSL.glsl b/src/webgl/shaders/functions/randomGLSL.glsl new file mode 100644 index 0000000000..0894462373 --- /dev/null +++ b/src/webgl/shaders/functions/randomGLSL.glsl @@ -0,0 +1,27 @@ +// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW) +// Mixing constants: Rβ‚‚ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/) +// α₁ = 1/Ο†β‚‚ = 0.7548776662 (plastic constant reciprocal) +// Ξ±β‚‚ = 1/Ο†β‚‚Β² = 0.5698402910 +// 1/Ο† = 0.6180339887 (golden ratio conjugate) + +int _p5_randomCallIndex = 0; + +float _p5_hash(vec3 p) { + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yxz + 33.33); + return fract((p.x + p.y) * p.z); +} + +float random(float seed) { + vec2 pixelCoord = gl_FragCoord.xy; + float callIndex = float(_p5_randomCallIndex); + _p5_randomCallIndex += 1; + // fract(seed * α₁) normalizes large seeds (e.g. performance.now()) into [0,1) + // and spreads them optimally via the Rβ‚‚ sequence's plastic constant + float s = fract(seed * 0.7548776662); + return _p5_hash(vec3( + pixelCoord.x + s, + pixelCoord.y + callIndex * 0.5698402910, + s + callIndex * 0.6180339887 + )); +} diff --git a/src/webgl/shaders/functions/randomVertGLSL.glsl b/src/webgl/shaders/functions/randomVertGLSL.glsl new file mode 100644 index 0000000000..2c9b1128bb --- /dev/null +++ b/src/webgl/shaders/functions/randomVertGLSL.glsl @@ -0,0 +1,25 @@ +// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW) +// Mixing constants: Rβ‚‚ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/) +// α₁ = 1/Ο†β‚‚ = 0.7548776662 (plastic constant reciprocal) +// Ξ±β‚‚ = 1/Ο†β‚‚Β² = 0.5698402910 +// 1/Ο† = 0.6180339887 (golden ratio conjugate) + +int _p5_randomCallIndex = 0; + +float _p5_hash(vec3 p) { + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yxz + 33.33); + return fract((p.x + p.y) * p.z); +} + +float random(float seed) { + float vid = float(gl_VertexID); + float callIndex = float(_p5_randomCallIndex); + _p5_randomCallIndex += 1; + float s = fract(seed * 0.7548776662); + return _p5_hash(vec3( + vid + s, + vid * 0.5698402910 + callIndex * 0.6180339887, + s + callIndex * 0.7548776662 + )); +} diff --git a/src/webgl/shaders/line.frag b/src/webgl/shaders/line.frag index a0ca059040..ec39236912 100644 --- a/src/webgl/shaders/line.frag +++ b/src/webgl/shaders/line.frag @@ -69,6 +69,7 @@ void main() { discard; } } - OUT_COLOR = HOOK_getFinalColor(vec4(inputs.color.rgb, 1.) * inputs.color.a); + OUT_COLOR = HOOK_getFinalColor(inputs.color, vec2(0.0, 0.0)); + OUT_COLOR.rgb *= OUT_COLOR.a; HOOK_afterFragment(); } diff --git a/src/webgl/shaders/normal.frag b/src/webgl/shaders/normal.frag index 0cb362265a..fbb9258547 100644 --- a/src/webgl/shaders/normal.frag +++ b/src/webgl/shaders/normal.frag @@ -1,6 +1,7 @@ IN vec3 vVertexNormal; +IN highp vec2 vVertTexCoord; void main(void) { HOOK_beforeFragment(); - OUT_COLOR = HOOK_getFinalColor(vec4(vVertexNormal, 1.0)); + OUT_COLOR = HOOK_getFinalColor(vec4(vVertexNormal, 1.0), vVertTexCoord); HOOK_afterFragment(); -} +} \ No newline at end of file diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag index 78cfb76163..47ec519d47 100644 --- a/src/webgl/shaders/phong.frag +++ b/src/webgl/shaders/phong.frag @@ -77,7 +77,7 @@ void main(void) { c.ambient = inputs.ambientLight; c.specular = specular; c.emissive = inputs.emissiveMaterial; - OUT_COLOR = HOOK_getFinalColor(HOOK_combineColors(c)); + OUT_COLOR = HOOK_getFinalColor(HOOK_combineColors(c), vTexCoord); OUT_COLOR.rgb *= OUT_COLOR.a; // Premultiply alpha before rendering HOOK_afterFragment(); } diff --git a/src/webgl/strands_glslBackend.js b/src/webgl/strands_glslBackend.js index daf804a8e8..bbd05a5950 100644 --- a/src/webgl/strands_glslBackend.js +++ b/src/webgl/strands_glslBackend.js @@ -1,4 +1,7 @@ -import { NodeType, OpCodeToSymbol, BlockType, OpCode, NodeTypeToName, isStructType, BaseType, StatementType, DataType } from "../strands/ir_types"; +import noiseGLSL from './shaders/functions/noise3DGLSL.glsl'; +import randomGLSL from './shaders/functions/randomGLSL.glsl'; +import randomVertGLSL from './shaders/functions/randomVertGLSL.glsl'; +import { NodeType, OpCodeToSymbol, BlockType, OpCode, NodeTypeToName, isStructType, BaseType, StatementType, DataType, INSTANCE_ID_VARYING_NAME, HOOK_PARAM_PREFIX } from "../strands/ir_types"; import { getNodeDataFromID, extractNodeTypeInfo } from "../strands/ir_dag"; import * as FES from '../strands/strands_FES'; import * as build from '../strands/ir_builders'; @@ -165,10 +168,19 @@ const cfgHandlers = { export const glslBackend = { hookEntry(hookType) { const firstLine = `(${hookType.parameters.flatMap((param) => { - return `${param.qualifiers?.length ? param.qualifiers.join(' ') : ''}${param.type.typeName} ${param.name}`; + return `${param.qualifiers?.length ? param.qualifiers.join(' ') : ''}${param.type.typeName} ${HOOK_PARAM_PREFIX}${param.name}`; }).join(', ')}) {`; return firstLine; }, + getNoiseShaderSnippet() { + return noiseGLSL; + }, + getRandomFragmentShaderSnippet() { + return randomGLSL; + }, + getRandomVertexShaderSnippet() { + return randomVertGLSL; + }, getTypeName(baseType, dimension) { const primitiveTypeName = TypeNames[baseType + dimension] if (!primitiveTypeName) { @@ -231,6 +243,10 @@ export const glslBackend = { return `${typeName} ${tmp} = ${expr};`; }, generateReturnStatement(strandsContext, generationContext, rootNodeID, returnType) { + if (!returnType) { + generationContext.write('return;'); + return; + } const dag = strandsContext.dag; const rootNode = getNodeDataFromID(dag, rootNodeID); if (isStructType(returnType)) { @@ -270,6 +286,13 @@ export const glslBackend = { sharedVar.usedInFragment = true; } } + + // Detect instanceID usage in fragment context and rewrite to varying name + if (node.identifier === this.instanceIdReference() && generationContext.shaderContext === 'fragment') { + generationContext.strandsContext._instanceIDUsedInFragment = true; + return INSTANCE_ID_VARYING_NAME; + } + return node.identifier; case NodeType.OPERATION: const useParantheses = node.usedBy.length > 0; @@ -289,6 +312,13 @@ export const glslBackend = { const functionArgs = node.dependsOn.map(arg =>this.generateExpression(generationContext, dag, arg)); return `${node.identifier}(${functionArgs.join(', ')})`; } + if (node.opCode === OpCode.Nary.TERNARY) { + const [condID, trueID, falseID] = node.dependsOn; + const cond = this.generateExpression(generationContext, dag, condID); + const trueExpr = this.generateExpression(generationContext, dag, trueID); + const falseExpr = this.generateExpression(generationContext, dag, falseID); + return `(${cond} ? ${trueExpr} : ${falseExpr})`; + } if (node.opCode === OpCode.Binary.MEMBER_ACCESS) { const [lID, rID] = node.dependsOn; const lName = this.generateExpression(generationContext, dag, lID); @@ -300,6 +330,12 @@ export const glslBackend = { const parentExpr = this.generateExpression(generationContext, dag, parentID); return `${parentExpr}.${node.swizzle}`; } + if (node.opCode === OpCode.Binary.ARRAY_ACCESS) { + const [bufferID, indexID] = node.dependsOn; + const bufferExpr = this.generateExpression(generationContext, dag, bufferID); + const indexExpr = this.generateExpression(generationContext, dag, indexID); + return `${bufferExpr}[${indexExpr}]`; + } if (node.dependsOn.length === 2) { const [lID, rID] = node.dependsOn; const left = this.generateExpression(generationContext, dag, lID); @@ -387,4 +423,8 @@ export const glslBackend = { instanceIdReference() { return 'gl_InstanceID'; }, + + generateInstanceIDVarying() { + return { name: INSTANCE_ID_VARYING_NAME, declaration: `int ${INSTANCE_ID_VARYING_NAME}`, source: 'gl_InstanceID', interpolation: 'flat' }; + }, } diff --git a/src/webgl/utils.js b/src/webgl/utils.js index c5c6e3d7c9..a5742c3f50 100644 --- a/src/webgl/utils.js +++ b/src/webgl/utils.js @@ -1,4 +1,5 @@ import * as constants from "../core/constants"; +import { INSTANCE_ID_VARYING_NAME } from "../strands/ir_types"; import { Texture } from "./p5.Texture"; /** @@ -6,8 +7,8 @@ import { Texture } from "./p5.Texture"; * @param {Uint8Array|Float32Array|undefined} pixels An existing pixels array to reuse if the size is the same * @param {WebGLRenderingContext} gl The WebGL context * @param {WebGLFramebuffer|null} framebuffer The Framebuffer to read - * @param {Number} x The x coordiante to read, premultiplied by pixel density - * @param {Number} y The y coordiante to read, premultiplied by pixel density + * @param {Number} x The x coordinate to read, premultiplied by pixel density + * @param {Number} y The y coordinate to read, premultiplied by pixel density * @param {Number} width The width in pixels to be read (factoring in pixel density) * @param {Number} height The height in pixels to be read (factoring in pixel density) * @param {GLEnum} format Either RGB or RGBA depending on how many channels to read @@ -429,6 +430,20 @@ export function populateGLSLHooks(shader, src, shaderType) { } } } + + // Handle instanceID varying for fragment access + if (shader.hooks.instanceIDVarying) { + const { declaration, source, interpolation } = shader.hooks.instanceIDVarying; + const qualifier = interpolation ? `${interpolation} ` : ''; + if (shaderType === "vertex") { + // Emit flat out declaration and inject assignment into main() body + hooks += `${qualifier}OUT ${declaration};\n`; + postMain = postMain.replace(/\{/, `{\n ${declaration.split(' ').pop()} = ${source};`); + } else if (shaderType === "fragment") { + hooks += `${qualifier}IN ${declaration};\n`; + } + } + for (const hookDef in shader.hooks.helpers) { hooks += `${hookDef}${shader.hooks.helpers[hookDef]}\n`; } diff --git a/src/webgpu/p5.RendererWebGPU.js b/src/webgpu/p5.RendererWebGPU.js index 678745f6fb..3b909230b7 100644 --- a/src/webgpu/p5.RendererWebGPU.js +++ b/src/webgpu/p5.RendererWebGPU.js @@ -1,6 +1,12 @@ +/** + * @module 3D + * @submodule p5.strands + * @for p5 + */ + import * as constants from '../core/constants'; import { getStrokeDefs } from '../webgl/enums'; -import { DataType } from '../strands/ir_types.js'; +import { DataType, INSTANCE_ID_VARYING_NAME } from '../strands/ir_types.js'; import { colorVertexShader, colorFragmentShader } from './shaders/color'; import { lineVertexShader, lineFragmentShader} from './shaders/line'; @@ -8,9 +14,10 @@ import { materialVertexShader, materialFragmentShader } from './shaders/material import { fontVertexShader, fontFragmentShader } from './shaders/font'; import { blitVertexShader, blitFragmentShader } from './shaders/blit'; import { wgslBackend } from './strands_wgslBackend'; -import noiseWGSL from './shaders/functions/noise3DWGSL'; + import { baseFilterVertexShader, baseFilterFragmentShader } from './shaders/filters/base'; import { imageLightVertexShader, imageLightDiffusedFragmentShader, imageLightSpecularFragmentShader } from './shaders/imageLight'; +import { baseComputeShader } from './shaders/compute'; const FRAME_STATE = { PENDING: 0, @@ -33,6 +40,338 @@ function rendererWebGPU(p5, fn) { RGBA, } = p5; + class StorageBuffer { + constructor(buffer, size, renderer, schema = null) { + this._isStorageBuffer = true; + this.buffer = buffer; + this.size = size; + this._renderer = renderer; + this._schema = schema; + } + + /** + * Updates the data in the buffer with new values. The new data must be in + * the same format as the data originally passed to + * `createStorage()`. + * + * ```js example + * let particles; + * let computeShader; + * let displayShader; + * let instance; + * const numParticles = 100; + * + * async function setup() { + * await createCanvas(100, 100, WEBGPU); + * particles = createStorage(makeParticles(width / 2, height / 2)); + * computeShader = buildComputeShader(simulate); + * displayShader = buildMaterialShader(display); + * instance = buildGeometry(drawParticle); + * describe('100 orange particles shooting outward.'); + * } + * + * function makeParticles(x, y) { + * let data = []; + * for (let i = 0; i < numParticles; i++) { + * let angle = (i / numParticles) * TWO_PI; + * let speed = random(0.5, 2); + * data.push({ + * position: createVector(x, y), + * velocity: createVector(cos(angle) * speed, sin(angle) * speed), + * }); + * } + * return data; + * } + * + * function drawParticle() { + * sphere(2); + * } + * + * function simulate() { + * let data = uniformStorage(particles); + * let idx = index.x; + * data[idx].position = data[idx].position + data[idx].velocity; + * } + * + * function display() { + * let data = uniformStorage(particles); + * worldInputs.begin(); + * let pos = data[instanceID()].position; + * worldInputs.position.xy += pos - [width / 2, height / 2]; + * worldInputs.end(); + * } + * + * function draw() { + * background(30); + * if (frameCount % 60 === 0) { + * particles.update(makeParticles(random(width), random(height))); + * } + * compute(computeShader, numParticles); + * noStroke(); + * fill(255, 200, 50); + * shader(displayShader); + * model(instance, numParticles); + * } + * ``` + * + * @method update + * @for p5.StorageBuffer + * @beta + * @webgpu + * @webgpuOnly + * @param {Number[]|Float32Array|Object[]} data The new data to write into the buffer. + */ + update(data) { + const device = this._renderer.device; + + if (this._schema !== null) { + // Buffer was created with a struct array + if ( + !Array.isArray(data) || + data.length === 0 || + typeof data[0] !== 'object' || + Array.isArray(data[0]) + ) { + throw new Error( + 'update() expects an array of objects matching the original struct format' + ); + } + + const newSchema = this._renderer._inferStructSchema(data[0]); + if (newSchema.structBody !== this._schema.structBody) { + throw new Error( + `update() data structure doesn't match the original.\n` + + ` Expected: ${this._schema.structBody}\n` + + ` Got: ${newSchema.structBody}` + ); + } + + const packed = this._renderer._packStructArray(data, this._schema); + if (packed.byteLength > this.size) { + throw new Error( + `update() data (${packed.byteLength} bytes) exceeds buffer size (${this.size} bytes)` + ); + } + device.queue.writeBuffer(this.buffer, 0, packed); + } else { + // Buffer was created with a float array + let floatData; + if (data instanceof Float32Array) { + floatData = data; + } else if (Array.isArray(data)) { + floatData = new Float32Array(data); + } else { + throw new Error( + 'update() expects a Float32Array or array of numbers for this buffer' + ); + } + + if (floatData.byteLength > this.size) { + throw new Error( + `update() data (${floatData.byteLength} bytes) exceeds buffer size (${this.size} bytes)` + ); + } + device.queue.writeBuffer(this.buffer, 0, floatData); + } + } + + /** + * Reads data from a storage buffer back into JavaScript. + * + * Copies data from the GPU to the CPU using a temporary buffer, + * so it must be awaited. Returns a `Float32Array` for number + * buffers, or an array of plain objects for struct buffers. + * + * Note: This is a GPU -> CPU read, so calling it often (like every frame) + * can be slow. + * + * ```js example + * let data; + * let computeShader; + * + * async function setup() { + * await createCanvas(100, 100, WEBGPU); + * + * data = createStorage(new Float32Array([1, 2, 3, 4])); + * computeShader = buildComputeShader(doubleValues); + * compute(computeShader, 4); + * + * let result = await data.read(); + * // result is Float32Array [2, 4, 6, 8] + * for (let i = 0; i < result.length; i++) { + * print(result[i]); + * } + * describe('Prints the values 2, 4, 6, 8 to the console.'); + * } + * + * function doubleValues() { + * let d = uniformStorage(data); + * let idx = index.x; + * d[idx] = d[idx] * 2; + * } + * ``` + * + * @method read + * @for p5.StorageBuffer + * @beta + * @webgpu + * @webgpuOnly + * @returns {Promise} + */ + async read() { + const device = this._renderer.device; + this._renderer.flushDraw(); + + const stagingBuffer = device.createBuffer({ + size: this.size, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + + const commandEncoder = device.createCommandEncoder(); + commandEncoder.copyBufferToBuffer(this.buffer, 0, stagingBuffer, 0, this.size); + device.queue.submit([commandEncoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ, 0, this.size); + const mappedRange = stagingBuffer.getMappedRange(0, this.size); + + // Copy before unmapping because mapped memory becomes invalid after unmap + const rawCopy = new Float32Array(mappedRange.byteLength / 4); + rawCopy.set(new Float32Array(mappedRange)); + + stagingBuffer.unmap(); + stagingBuffer.destroy(); + + if (this._schema !== null) { + return this._renderer._unpackStructArray(rawCopy, this._schema); + } + return rawCopy; + } + + /** + * Updates a single element in the buffer at a given index. Use this + * when only a small number of elements need to change. If you need to + * replace all the data at once, use + * `update()` instead. + * + * ```js + * let buf; + * + * async function setup() { + * await createCanvas(100, 100, WEBGPU); + * + * // Float buffer: update one value by index + * buf = createStorage(new Float32Array([1, 2, 3, 4])); + * buf.set(2, 9.5); // only index 2 changes β†’ [1, 2, 9.5, 4] + * + * let result = await buf.read(); + * print(result[2]); // 9.5 + * describe('Prints 9.5 to the console.'); + * } + * ``` + * + * ```js + * let particles; + * const numParticles = 100; + * + * async function setup() { + * await createCanvas(100, 100, WEBGPU); + * particles = createStorage(makeParticles()); + * + * // Replace particle 42 without touching the others + * particles.set(42, { + * position: createVector(0, 0), + * velocity: createVector(1, 0), + * }); + * + * // Read back to confirm the update + * let result = await particles.read(); + * print(result[42].position.x, result[42].position.y); // 0, 0 + * describe('Prints the position of particle 42 after updating it.'); + * } + * + * function makeParticles() { + * let data = []; + * for (let i = 0; i < numParticles; i++) { + * data.push({ + * position: createVector(random(width), random(height)), + * velocity: createVector(random(-1, 1), random(-1, 1)), + * }); + * } + * return data; + * } + * ``` + * + * @method set + * @for p5.StorageBuffer + * @beta + * @webgpu + * @webgpuOnly + * @param {Number} index The zero-based index of the element to update. + * @param {Number|Object} value The new value. Pass a number for float + * buffers, or a plain object matching the original struct layout for + * struct buffers. + */ + set(index, value) { + const device = this._renderer.device; + + if (this._schema !== null) { + // buffer was created with an array of structs + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error( + 'set() expects a plain object matching the original struct format for this buffer' + ); + } + + const { stride } = this._schema; + const byteOffset = index * stride; + + if (byteOffset + stride > this.size) { + throw new Error( + `set() index ${index} is out of bounds for this buffer ` + + `(buffer holds ${Math.floor(this.size / stride)} elements)` + ); + } + + // pack just this one element using the same logic as update() + const packed = this._renderer._packStructArray([value], this._schema); + // use packed.buffer (ArrayBuffer) so the size arg is always in bytes + device.queue.writeBuffer(this.buffer, byteOffset, packed.buffer, 0, stride); + } else { + // buffer was created with a float array + if (typeof value !== 'number') { + throw new Error( + 'set() expects a number for this float buffer' + ); + } + + const byteOffset = index * 4; + + if (byteOffset + 4 > this.size) { + throw new Error( + `set() index ${index} is out of bounds for this buffer ` + + `(buffer holds ${Math.floor(this.size / 4)} floats)` + ); + } + + device.queue.writeBuffer(this.buffer, byteOffset, new Float32Array([value])); + } + } + } + + /** + * A block of data that shaders can read from, and compute shaders can also + * write to. This is only available in WebGPU mode. + * + * Note: `createStorage()` is the recommended + * way to create an instance of this class. + * + * @class p5.StorageBuffer + * @beta + * @webgpu + * @webgpuOnly + */ + p5.StorageBuffer = StorageBuffer; + class RendererWebGPU extends Renderer3D { constructor(pInst, w, h, isMainCanvas, elt) { super(pInst, w, h, isMainCanvas, elt) @@ -84,6 +423,9 @@ function rendererWebGPU(p5, fn) { // Retired buffers to destroy at end of frame this._retiredBuffers = []; + // Storage buffers for compute shaders + this._storageBuffers = new Set(); + // 2D canvas for pixel reading fallback this._pixelReadCanvas = null; this._pixelReadCtx = null; @@ -160,7 +502,7 @@ function rendererWebGPU(p5, fn) { } if (this._pInst._webgpuAttributes[key] !== value) { //changing value of previously altered attribute - this._webgpuAttributes[key] = value; + this._pInst._webgpuAttributes[key] = value; unchanged = false; } //setting all attributes with some change @@ -294,9 +636,21 @@ function rendererWebGPU(p5, fn) { const _b = args[2] || 0; const _a = args[3] || 0; - // If PENDING and no custom framebuffer, clear means stay UNPROMOTED - if (this._frameState === FRAME_STATE.PENDING && !this.activeFramebuffer()) { - this._frameState = FRAME_STATE.UNPROMOTED; + // If PENDING and no custom framebuffer, clear means stay UNPROMOTED. + // However, if we are still in setup (frameCount == 0), we must promote + // so that mainFramebuffer gets the cleared content. This ensures that if + // draw() later promotes without a copy, it starts from the correct state + // rather than a stale mainFramebuffer. + // Note: a mid-draw-loop transition from UNPROMOTED back to PROMOTED + // (i.e. calling background() some frames but not others) will still + // lose intermediate UNPROMOTED frame content. + if (this._frameState !== FRAME_STATE.PROMOTED && !this.activeFramebuffer()) { + if (this._pInst.frameCount > 0) { + this._frameState = FRAME_STATE.UNPROMOTED; + } else { + this._promoteToFramebufferWithoutCopy(); + // clear() then targets mainFramebuffer via activeFramebuffer() + } } this._finishActiveRenderPass(); @@ -499,7 +853,8 @@ function rendererWebGPU(p5, fn) { return 4; // Cap at 4 for broader compatibility } - _shaderOptions({ mode }) { + _shaderOptions({ mode, compute, workgroupSize }) { + if (compute) return { compute: true, workgroupSize }; const activeFramebuffer = this.activeFramebuffer(); const format = activeFramebuffer ? this._getWebGPUColorFormat(activeFramebuffer) : @@ -510,9 +865,9 @@ function rendererWebGPU(p5, fn) { 1; // No MSAA needed when blitting already-antialiased textures to canvas const sampleCount = this._getValidSampleCount(requestedSampleCount); - const depthFormat = activeFramebuffer && activeFramebuffer.useDepth ? - this._getWebGPUDepthFormat(activeFramebuffer) : - this.depthFormat; + const depthFormat = activeFramebuffer + ? (activeFramebuffer.useDepth ? this._getWebGPUDepthFormat(activeFramebuffer) : undefined) + : this.depthFormat; const drawTarget = this.drawTarget(); const clipping = this._clipping; @@ -540,6 +895,31 @@ function rendererWebGPU(p5, fn) { _initShader(shader) { const device = this.device; + if (shader.shaderType === 'compute') { + // Compute shader initialization + shader.computeModule = device.createShaderModule({ code: shader.computeSrc() }); + shader._computePipelineCache = null; + shader._workgroupSize = null; + + // Create compute pipeline (deferred until first compute() call) + shader.getPipeline = ({ workgroupSize }) => { + if (!shader._computePipelineCache) { + shader._computePipelineCache = device.createComputePipeline({ + layout: shader._pipelineLayout, + compute: { + module: shader.computeModule, + entryPoint: 'main' + } + }); + shader._workgroupSize = workgroupSize; + } + return shader._computePipelineCache; + }; + + return; + } + + // Render shader initialization shader.vertModule = device.createShaderModule({ code: shader.vertSrc() }); shader.fragModule = device.createShaderModule({ code: shader.fragSrc() }); @@ -564,25 +944,27 @@ function rendererWebGPU(p5, fn) { }, primitive: { topology }, multisample: { count: sampleCount }, - depthStencil: { - format: depthFormat, - depthWriteEnabled: !clipping, - depthCompare: 'less-equal', - stencilFront: { - compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'), - failOp: 'keep', - depthFailOp: 'keep', - passOp: clipping ? 'replace' : 'keep', - }, - stencilBack: { - compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'), - failOp: 'keep', - depthFailOp: 'keep', - passOp: clipping ? 'replace' : 'keep', + ...(depthFormat ? { + depthStencil: { + format: depthFormat, + depthWriteEnabled: !clipping, + depthCompare: 'less-equal', + stencilFront: { + compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'), + failOp: 'keep', + depthFailOp: 'keep', + passOp: clipping ? 'replace' : 'keep', + }, + stencilBack: { + compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'), + failOp: 'keep', + depthFailOp: 'keep', + passOp: clipping ? 'replace' : 'keep', + }, + stencilReadMask: 0xFF, + stencilWriteMask: clipping ? 0xFF : 0x00, }, - stencilReadMask: 0xFF, - stencilWriteMask: clipping ? 0xFF : 0x00, - }, + } : {}), }); shader._pipelineCache.set(key, pipeline); } @@ -638,7 +1020,9 @@ function rendererWebGPU(p5, fn) { entries.push({ bufferGroup, binding: bufferGroup.binding, - visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + visibility: shader.shaderType === 'compute' + ? GPUShaderStage.COMPUTE + : GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform', hasDynamicOffset: bufferGroup.dynamic }, }); structEntries.set(bufferGroup.group, entries); @@ -672,6 +1056,24 @@ function rendererWebGPU(p5, fn) { groupEntries.set(group, entries); } + // Add storage buffer bindings + for (const storage of shader._storageBuffers || []) { + const group = storage.group; + const entries = groupEntries.get(group) || []; + + entries.push({ + binding: storage.binding, + visibility: storage.visibility, + buffer: { + type: storage.accessMode === 'read' ? 'read-only-storage' : 'storage' + }, + storage: storage, + }); + + entries.sort((a, b) => a.binding - b.binding); + groupEntries.set(group, entries); + } + // Create layouts and bind groups const groupEntriesArr = []; for (const [group, entries] of groupEntries) { @@ -690,6 +1092,7 @@ function rendererWebGPU(p5, fn) { shader._pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: shader._bindGroupLayouts, }); + shader._compiled = true; } _getBlendState(mode) { @@ -936,8 +1339,11 @@ function rendererWebGPU(p5, fn) { _resetBuffersBeforeDraw() { this._finishActiveRenderPass(); + // Set state to PENDING - we'll decide on first draw - this._frameState = FRAME_STATE.PENDING; + if (this._pInst.frameCount > 0) { + this._frameState = FRAME_STATE.PENDING; + } // Clear depth buffer but DON'T start any render pass yet const activeFramebuffer = this.activeFramebuffer(); @@ -1048,6 +1454,8 @@ function rendererWebGPU(p5, fn) { // once we're drawing to the framebuffer, because normally // those are reset. const savedModelMatrix = this.states.uModelMatrix.copy(); + this.states.uModelMatrix.set(this.states.uModelMatrix.copy()); + this.states.uModelMatrix.reset(); this.mainFramebuffer.defaultCamera.set(this.states.curCamera); this.mainFramebuffer.begin(); @@ -1056,6 +1464,11 @@ function rendererWebGPU(p5, fn) { } _promoteToFramebufferWithoutCopy() { + // Already promoted this frame + if (this._frameState === FRAME_STATE.PROMOTED) { + return; + } + // Ensure mainFramebuffer matches canvas size if (this.mainFramebuffer.width !== this.width || this.mainFramebuffer.height !== this.height) { @@ -1070,6 +1483,8 @@ function rendererWebGPU(p5, fn) { // Preserve transformation state const savedModelMatrix = this.states.uModelMatrix.copy(); + this.states.uModelMatrix.set(this.states.uModelMatrix.copy()); + this.states.uModelMatrix.reset(); this.mainFramebuffer.defaultCamera.set(this.states.curCamera); // Begin rendering to mainFramebuffer @@ -1383,7 +1798,6 @@ function rendererWebGPU(p5, fn) { } this.flushDraw(); - // this._pInst.background('red'); this._pInst.push(); this.states.setValue('enableLighting', false); this.states.setValue('activeImageLight', null); @@ -1448,25 +1862,9 @@ function rendererWebGPU(p5, fn) { this._beginActiveRenderPass(); const passEncoder = this.activeRenderPass; - const currentShader = this._curShader; - const shaderOptions = this._shaderOptions({ mode }); - if (this.activeShader !== currentShader || this._shaderOptionsDifferent(shaderOptions)) { - passEncoder.setPipeline(currentShader.getPipeline(shaderOptions)); - } - this.activeShader = currentShader; - this.activeShaderOptions = shaderOptions; - // Set stencil reference value for clipping - const drawTarget = this.drawTarget(); - if (drawTarget._isClipApplied && !this._clipping) { - // When using the clip mask, test against reference value 0 (background) - // WebGL uses NOTEQUAL with ref 0, so fragments pass where stencil != 0 - // In WebGPU with 'not-equal', we need ref 0 to pass where stencil != 0 - passEncoder.setStencilReference(0); - } else if (this._clipping) { - // When writing to the clip mask, write reference value 1 - passEncoder.setStencilReference(1); - } + const currentShader = this._curShader; + this.setupShaderBindGroups(currentShader, passEncoder, { mode, buffers }); // Bind vertex buffers for (const buffer of currentShader._vertexBuffers || this._getVertexBuffers(currentShader)) { const location = currentShader.attributes[buffer.attr].location; @@ -1474,6 +1872,58 @@ function rendererWebGPU(p5, fn) { passEncoder.setVertexBuffer(location, gpuBuffer, 0); } + if (currentShader.shaderType === "fill") { + // Bind index buffer and issue draw + if (buffers.indexBuffer) { + const indexFormat = buffers.indexFormat || "uint16"; + passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat); + passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0); + } else { + passEncoder.draw(geometry.vertices.length, count, 0, 0); + } + } else if (currentShader.shaderType === "text") { + if (!buffers.indexBuffer) { + throw new Error("Text geometry must have an index buffer"); + } + const indexFormat = buffers.indexFormat || "uint16"; + passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat); + passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0); + } + + if (buffers.lineVerticesBuffer && currentShader.shaderType === "stroke") { + passEncoder.draw(geometry.lineVertices.length / 3, count, 0, 0); + } + + // Mark that we have pending draws that need submission + this._hasPendingDraws = true; + } + + setupShaderBindGroups(currentShader, passEncoder, shaderOptionsParams) { + const shaderOptions = this._shaderOptions(shaderOptionsParams); + if ( + shaderOptions.compute || + this.activeShader !== currentShader || + this._shaderOptionsDifferent(shaderOptions) + ) { + passEncoder.setPipeline(currentShader.getPipeline(shaderOptions)); + } + if (!shaderOptions.compute) { + this.activeShader = currentShader; + this.activeShaderOptions = shaderOptions; + + // Set stencil reference value for clipping + const drawTarget = this.drawTarget(); + if (drawTarget._isClipApplied && !this._clipping) { + // When using the clip mask, test against reference value 0 (background) + // WebGL uses NOTEQUAL with ref 0, so fragments pass where stencil != 0 + // In WebGPU with 'not-equal', we need ref 0 to pass where stencil != 0 + passEncoder.setStencilReference(0); + } else if (this._clipping) { + // When writing to the clip mask, write reference value 1 + passEncoder.setStencilReference(1); + } + } + for (const bufferGroup of currentShader._uniformBufferGroups) { if (bufferGroup.dynamic) { // Bind uniforms into a part of a big dynamic memory block because @@ -1526,6 +1976,13 @@ function rendererWebGPU(p5, fn) { currentShader.buffersDirty.delete(key); } } + for (const storage of currentShader._storageBuffers || []) { + const key = storage.group * 1000 + storage.binding; + if (currentShader.buffersDirty.has(key)) { + currentShader._cachedBindGroup[storage.group] = undefined; + currentShader.buffersDirty.delete(key); + } + } // Bind sampler/texture uniforms and uniform buffers for (const iter of currentShader._groupEntries) { @@ -1555,6 +2012,19 @@ function rendererWebGPU(p5, fn) { : { buffer: uniformBufferInfo.buffer }, }); } + } else if (entry.storage && !bindGroup) { + // Storage buffer binding + const uniform = currentShader.uniforms[entry.storage.name]; + if (!uniform || !uniform._cachedData || !uniform._cachedData._isStorageBuffer) { + throw new Error( + `Storage buffer "${entry.storage.name}" not set. ` + + `Use shader.setUniform("${entry.storage.name}", storageBuffer)` + ); + } + bgEntries.push({ + binding: entry.binding, + resource: { buffer: uniform._cachedData.buffer }, + }); } else if (!bindGroup) { bgEntries.push({ binding: entry.binding, @@ -1588,84 +2058,71 @@ function rendererWebGPU(p5, fn) { ); } } + return passEncoder; + } - if (currentShader.shaderType === "fill") { - // Bind index buffer and issue draw - if (buffers.indexBuffer) { - const indexFormat = buffers.indexFormat || "uint16"; - passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat); - passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0); + ////////////////////////////////////////////// + // SHADER + ////////////////////////////////////////////// + + // Writes a single field's value into a Float32Array+DataView at (baseOffset + field.offset). + // + // Field interface (shared by uniform fields from _parseStruct and struct storage schema fields): + // baseType: string - 'f32', 'i32', 'u32', etc. + // size: number - byte size of the field + // offset: number - byte offset of the field within its struct + // packInPlace: bool - true for mat3, written with manual column padding + // + // value: number or number[] - the data to write + _packField(field, value, floatView, dataView, baseOffset) { + if (value === undefined) return; + + // Duck typing instead of instanceof to avoid importing a separate + // copy of the Color/Vector classes + if (value?.isVector) { + value = value.values.length !== value.dimensions ? value.values.slice(0, value.dimensions) : value.values; + } else if (value?.isColor) { + value = value._getRGBA([1, 1, 1, 1]); + } + const byteOffset = baseOffset + field.offset; + if (field.baseType === 'u32') { + if (field.size === 4) { + dataView.setUint32(byteOffset, value, true); } else { - passEncoder.draw(geometry.vertices.length, count, 0, 0); + for (let i = 0; i < value.length; i++) { + dataView.setUint32(byteOffset + i * 4, value[i], true); + } } - } else if (currentShader.shaderType === "text") { - if (!buffers.indexBuffer) { - throw new Error("Text geometry must have an index buffer"); + } else if (field.baseType === 'i32') { + if (field.size === 4) { + dataView.setInt32(byteOffset, value, true); + } else { + for (let i = 0; i < value.length; i++) { + dataView.setInt32(byteOffset + i * 4, value[i], true); + } } - const indexFormat = buffers.indexFormat || "uint16"; - passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat); - passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0); - } - - if (buffers.lineVerticesBuffer && currentShader.shaderType === "stroke") { - passEncoder.draw(geometry.lineVertices.length / 3, count, 0, 0); + } else if (field.packInPlace) { + // In-place packing for mat3: write directly to buffer with padding + const base = byteOffset / 4; + floatView[base + 0] = value[0]; floatView[base + 1] = value[1]; floatView[base + 2] = value[2]; + floatView[base + 4] = value[3]; floatView[base + 5] = value[4]; floatView[base + 6] = value[5]; + floatView[base + 8] = value[6]; floatView[base + 9] = value[7]; floatView[base + 10] = value[8]; + } else if (field.size === 4) { + floatView.set([value], byteOffset / 4); + } else { + floatView.set(value, byteOffset / 4); } - - // Mark that we have pending draws that need submission - this._hasPendingDraws = true; } - ////////////////////////////////////////////// - // SHADER - ////////////////////////////////////////////// - _packUniformGroup(shader, groupUniforms, bufferInfo) { // Pack a single group's uniforms into a buffer const data = bufferInfo.data; const dataView = bufferInfo.dataView; - const offset = bufferInfo.offset || 0; for (const uniform of groupUniforms) { const fullUniform = shader.uniforms[uniform.name]; if (!fullUniform || fullUniform.isSampler) continue; - const uniformData = fullUniform._mappedData; - - if (fullUniform.baseType === 'u32') { - if (fullUniform.size === 4) { - dataView.setUint32(offset + fullUniform.offset, uniformData, true); - } else { - for (let i = 0; i < uniformData.length; i++) { - dataView.setUint32(offset + fullUniform.offset + i * 4, uniformData[i], true); - } - } - } else if (fullUniform.baseType === 'i32') { - if (fullUniform.size === 4) { - dataView.setInt32(offset + fullUniform.offset, uniformData, true); - } else { - for (let i = 0; i < uniformData.length; i++) { - dataView.setInt32(offset + fullUniform.offset + i * 4, uniformData[i], true); - } - } - } else if (fullUniform.packInPlace) { - // In-place packing for mat3: write directly to buffer with padding - const baseOffset = (offset + fullUniform.offset) / 4; - // Column 0 - data[baseOffset + 0] = uniformData[0]; - data[baseOffset + 1] = uniformData[1]; - data[baseOffset + 2] = uniformData[2]; - // Column 1 - data[baseOffset + 4] = uniformData[3]; - data[baseOffset + 5] = uniformData[4]; - data[baseOffset + 6] = uniformData[5]; - // Column 2 - data[baseOffset + 8] = uniformData[6]; - data[baseOffset + 9] = uniformData[7]; - data[baseOffset + 10] = uniformData[8]; - } else if (fullUniform.size === 4) { - data.set([uniformData], (offset + fullUniform.offset) / 4); - } else if (uniformData !== undefined) { - data.set(uniformData, (offset + fullUniform.offset) / 4); - } + this._packField(fullUniform, fullUniform._mappedData, data, dataView, offset); } } @@ -1812,10 +2269,11 @@ function rendererWebGPU(p5, fn) { const uniformVarRegex = /@group\((\d+)\)\s+@binding\((\d+)\)\s+var\s+(\w+)\s*:\s*(\w+);/g; let match; - while ((match = uniformVarRegex.exec(shader.vertSrc())) !== null) { + const src = shader.shaderType === 'compute' ? shader.computeSrc() : shader.vertSrc(); + while ((match = uniformVarRegex.exec(src)) !== null) { const [_, groupNum, binding, varName, structType] = match; const bindingIndex = parseInt(binding); - const uniforms = this._parseStruct(shader.vertSrc(), structType); + const uniforms = this._parseStruct(src, structType); uniformGroups.push({ group: parseInt(groupNum), @@ -1826,7 +2284,7 @@ function rendererWebGPU(p5, fn) { }); } - if (uniformGroups.length === 0) { + if (uniformGroups.length === 0 && shader.shaderType !== 'compute') { throw new Error('Expected at least one uniform struct bound to @group(0)'); } @@ -1853,6 +2311,10 @@ function rendererWebGPU(p5, fn) { // TODO: support other texture types const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*(texture_2d|sampler);/g; + // Extract storage buffers + const storageBuffers = {}; + const storageRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*array<\w+>/g; + // Track which bindings are taken by the struct properties we've parsed // (the rest should be textures/samplers) const structUniformBindings = {}; @@ -1862,8 +2324,11 @@ function rendererWebGPU(p5, fn) { for (const [src, visibility] of [ [shader.vertSrc(), GPUShaderStage.VERTEX], - [shader.fragSrc(), GPUShaderStage.FRAGMENT] + [shader.fragSrc(), GPUShaderStage.FRAGMENT], + [shader.computeSrc ? shader.computeSrc() : null, GPUShaderStage.COMPUTE] ]) { + if (!src) continue; // Skip if shader stage doesn't exist + let match; while ((match = samplerRegex.exec(src)) !== null) { const [_, group, binding, name, type] = match; @@ -1898,21 +2363,51 @@ function rendererWebGPU(p5, fn) { samplerNode.textureSource = sampler; } } + + // Parse storage buffers + while ((match = storageRegex.exec(src)) !== null) { + const [_, group, binding, accessMode, name] = match; + const groupIndex = parseInt(group); + const bindingIndex = parseInt(binding); + + const key = `${groupIndex},${bindingIndex}`; + const existing = storageBuffers[key]; + // If any stage uses read_write, the bind group layout must use read_write + const finalAccessMode = (existing?.accessMode === 'read_write' || accessMode === 'read_write') + ? 'read_write' + : accessMode; + + storageBuffers[key] = { + visibility: (existing?.visibility || 0) | visibility, + group: groupIndex, + binding: bindingIndex, + name, + accessMode: finalAccessMode, // 'read' or 'read_write' + isStorage: true, + type: 'storage' + }; + } } - return [...Object.values(allUniforms).sort((a, b) => a.index - b.index), ...Object.values(samplers)]; + + // Store storage buffers on shader for later use + shader._storageBuffers = Object.values(storageBuffers); + + return [...Object.values(allUniforms).sort((a, b) => a.index - b.index), ...Object.values(samplers), ...Object.values(storageBuffers)]; } - getNextBindingIndex({ vert, frag }, group = 0) { + getNextBindingIndex({ vert, frag, compute }, group = 0) { // Get the highest binding index in the specified group and return the next available - const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var(?:)?\s+(\w+)\s*:\s*(texture_2d|sampler|uniform|\w+)/g; + const bindingRegex = /@group\((\d+)\)\s*@binding\((\d+)\)/g; let maxBindingIndex = -1; - for (const [src, visibility] of [ - [vert, GPUShaderStage.VERTEX], - [frag, GPUShaderStage.FRAGMENT] - ]) { + const sources = []; + if (vert) sources.push([vert, GPUShaderStage.VERTEX]); + if (frag) sources.push([frag, GPUShaderStage.FRAGMENT]); + if (compute) sources.push([compute, GPUShaderStage.COMPUTE]); + + for (const [src, visibility] of sources) { let match; - while ((match = samplerRegex.exec(src)) !== null) { + while ((match = bindingRegex.exec(src)) !== null) { const [_, groupIndex, bindingIndex] = match; if (parseInt(groupIndex) === group) { maxBindingIndex = Math.max(maxBindingIndex, parseInt(bindingIndex)); @@ -1927,7 +2422,7 @@ function rendererWebGPU(p5, fn) { if (uniform.isSampler) { uniform.texture = data instanceof Texture ? data : this.getTexture(data); - } else { + } else if (!data?._isStorageBuffer) { uniform._mappedData = this._mapUniformData(uniform, uniform._cachedData); } shader.buffersDirty.add(uniform.group * 1000 + uniform.binding); @@ -2034,7 +2529,7 @@ function rendererWebGPU(p5, fn) { rgb += components.emissive; return vec4(rgb, components.opacity); }`, - "vec4f getFinalColor": "(color: vec4) { return color; }", + "vec4f getFinalColor": "(color: vec4, texCoord: vec2) { return color; }", "void afterFragment": "() {}", }, } @@ -2059,7 +2554,7 @@ function rendererWebGPU(p5, fn) { }, fragment: { "void beforeFragment": "() {}", - "vec4 getFinalColor": "(color: vec4) { return color; }", + "vec4 getFinalColor": "(color: vec4, texCoord: vec2) { return color; }", "void afterFragment": "() {}", }, } @@ -2085,7 +2580,7 @@ function rendererWebGPU(p5, fn) { fragment: { "void beforeFragment": "() {}", "Inputs getPixelInputs": "(inputs: Inputs) { return inputs; }", - "vec4 getFinalColor": "(color: vec4) { return color; }", + "vec4 getFinalColor": "(color: vec4, texCoord: vec2) { return color; }", "bool shouldDiscard": "(outside: bool) { return outside; };", "void afterFragment": "() {}", }, @@ -2244,11 +2739,87 @@ function rendererWebGPU(p5, fn) { } ); - let [preMain, main, postMain] = src.split(/((?:@(?:vertex|fragment)\s*)?fn main[^{]+\{)/); - if (shaderType !== 'fragment') { - if (!main.match(/\@builtin\s*\(\s*instance_index\s*\)/)) { - main = main.replace(/\)\s*(->|\{)/, ', @builtin(instance_index) instanceID: u32) $1'); + let [preMain, main, postMain] = src.split(/((?:@(?:vertex|fragment|compute)\s*(?:@workgroup_size\([^)]+\)\s*)?)?fn main[^{]+\{)/); + + const getBuiltinParamName = (mainSrc, builtinName) => { + const match = new RegExp(`@builtin\\s*\\(\\s*${builtinName}\\s*\\)\\s*(\\w+)\\s*:`).exec(mainSrc); + return match ? match[1] : null; + }; + + const ensureBuiltinParam = (mainSrc, builtinName, fallbackName, typeName) => { + const existingName = getBuiltinParamName(mainSrc, builtinName); + if (existingName) { + return { mainSrc, argName: existingName }; } + + const hasParams = /\(\s*\S/.test(mainSrc); + const injectedMain = mainSrc.replace( + /\)\s*(->|\{)/, + `${hasParams ? ', ' : ''}@builtin(${builtinName}) ${fallbackName}: ${typeName}) $1` + ); + + return { mainSrc: injectedMain, argName: fallbackName }; + }; + + const getMainStructParameter = (mainSrc) => { + const match = /fn main\s*\(\s*(\w+)\s*:\s*(\w+)/.exec(mainSrc); + if (!match) return null; + return { inputName: match[1], structName: match[2] }; + }; + + const getStructBuiltinFieldName = (structName, builtinName) => { + const structMatch = new RegExp(`struct\\s+${structName}\\s*\\{([^}]*)\\}`, 's').exec(preMain); + if (!structMatch) return null; + const fieldMatch = new RegExp(`@builtin\\s*\\(\\s*${builtinName}\\s*\\)\\s*(\\w+)\\s*:`, 's').exec(structMatch[1]); + return fieldMatch ? fieldMatch[1] : null; + }; + + const appendHookParams = (params, additionalParams) => { + if (additionalParams.length === 0) return params; + const hasParams = !/^\(\s*\)$/.test(params); + return `${params.slice(0, -1)}${hasParams ? ', ' : ''}${additionalParams.join(', ')})`; + }; + + let hookExtraParams = []; + let hookExtraArgs = []; + + if (shaderType === 'vertex') { + const ensuredInstance = ensureBuiltinParam(main, 'instance_index', 'instanceID', 'u32'); + main = ensuredInstance.mainSrc; + + const ensuredVertex = ensureBuiltinParam(main, 'vertex_index', '_p5VertexId', 'u32'); + main = ensuredVertex.mainSrc; + + hookExtraParams = ['instanceID: u32', '_p5VertexId: u32']; + hookExtraArgs = [ensuredInstance.argName, ensuredVertex.argName]; + } else if (shaderType === 'fragment') { + const directPositionArg = getBuiltinParamName(main, 'position'); + let fragmentPositionArg = directPositionArg; + + if (!fragmentPositionArg) { + const mainStructParam = getMainStructParameter(main); + if (mainStructParam) { + const positionField = getStructBuiltinFieldName(mainStructParam.structName, 'position'); + if (positionField) { + fragmentPositionArg = `${mainStructParam.inputName}.${positionField}`; + } + } + } + + if (!fragmentPositionArg) { + const ensuredPosition = ensureBuiltinParam(main, 'position', '_p5FragPos', 'vec4'); + main = ensuredPosition.mainSrc; + fragmentPositionArg = ensuredPosition.argName; + } + + hookExtraParams = ['_p5FragPos: vec4']; + hookExtraArgs = [fragmentPositionArg]; + } else if (shaderType === 'compute') { + const ensuredGlobalId = ensureBuiltinParam(main, 'global_invocation_id', '_p5GlobalId', 'vec3'); + main = ensuredGlobalId.mainSrc; + + hookExtraParams = ['_p5GlobalId: vec3']; + hookExtraArgs = [ensuredGlobalId.argName]; } // Inject hook uniforms as a separate struct at a new binding @@ -2268,6 +2839,7 @@ function rendererWebGPU(p5, fn) { const nextBinding = this.getNextBindingIndex({ vert: shaderType === 'vertex' ? preMain + (shader.hooks.vertex?.declarations ?? '') + shader.hooks.declarations : shader._vertSrc, frag: shaderType === 'fragment' ? preMain + (shader.hooks.fragment?.declarations ?? '') + shader.hooks.declarations : shader._fragSrc, + compute: shaderType === 'compute' ? preMain + (shader.hooks.compute?.declarations ?? '') + shader.hooks.declarations : shader._computeSrc, }, 0); // Create HookUniforms struct and binding @@ -2278,8 +2850,14 @@ ${hookUniformFields}} @group(0) @binding(${nextBinding}) var hooks: HookUniforms; `; - // Insert before the first @group binding - preMain = preMain.replace(/(@group\(0\)\s+@binding)/, `${hookUniformsDecl}\n$1`); + // Insert before the first @group binding, or at the end if there are none + const replaced = preMain.replace(/(@group\(0\)\s+@binding)/, `${hookUniformsDecl}\n$1`); + if (replaced === preMain) { + // No @group bindings found in base shader, append to preMain + preMain = preMain + '\n' + hookUniformsDecl; + } else { + preMain = replaced; + } } // Handle varying variables by injecting them into VertexOutput and FragmentInput structs @@ -2345,10 +2923,9 @@ ${hookUniformFields}} initStatements += ` ${varName} = INPUT_VAR.${varName};\n`; } - // Find the input parameter name from the main function signature (anchored to start) - const inputMatch = main.match(/fn main\s*\((\w+):\s*\w+\)/); - if (inputMatch) { - const inputVarName = inputMatch[1]; + const mainStructParam = getMainStructParameter(main); + if (mainStructParam) { + const inputVarName = mainStructParam.inputName; initStatements = initStatements.replace(/INPUT_VAR/g, inputVarName); // Insert after the main function parameter but before any other code (anchored to start) postMain = initStatements + postMain; @@ -2356,12 +2933,56 @@ ${hookUniformFields}} } } + // Handle instanceID varying for fragment access + if (shader.hooks.instanceIDVarying) { + const { name, declaration, source, interpolation } = shader.hooks.instanceIDVarying; + const nextLocIndex = this._getNextAvailableLocation(preMain, shaderType); + const interpAttr = interpolation ? ` @interpolate(${interpolation})` : ''; + const [varName, varType] = declaration.split(':').map(s => s.trim()); + const structMember = `@location(${nextLocIndex})${interpAttr} ${declaration},`; + + if (shaderType === 'vertex') { + // Inject into VertexOutput struct + preMain = preMain.replace( + /struct\s+VertexOutput\s+\{([^}]*)\}/, + (match, body) => `struct VertexOutput {${body}\n${structMember}}` + ); + // Add private global + preMain += `var ${declaration};\n`; + // Assign from built-in instanceID at start of main() + postMain = `\n ${varName} = ${source};\n` + postMain; + // Copy to output struct before return + const returnMatch = postMain.match(/return\s+(\w+)\s*;/); + if (returnMatch) { + const outputVarName = returnMatch[1]; + postMain = postMain.replace( + /(return\s+\w+\s*;)/g, + `${outputVarName}.${varName} = ${varName};\n $1` + ); + } + } else if (shaderType === 'fragment') { + // Inject into FragmentInput struct + preMain = preMain.replace( + /struct\s+FragmentInput\s+\{([^}]*)\}/, + (match, body) => `struct FragmentInput {${body}\n${structMember}}` + ); + // Add private global + preMain += `var ${declaration};\n`; + // Initialize from input struct at start of main() + const mainStructParam = getMainStructParameter(main); + if (mainStructParam) { + const inputVarName = mainStructParam.inputName; + postMain = `\n ${varName} = ${inputVarName}.${varName};\n` + postMain; + } + } + } + let hooks = ''; let defines = ''; if (shader.hooks.declarations) { hooks += shader.hooks.declarations + '\n'; } - if (shader.hooks[shaderType].declarations) { + if (shader.hooks[shaderType] && shader.hooks[shaderType].declarations) { hooks += shader.hooks[shaderType].declarations + '\n'; } for (const hookDef in shader.hooks.helpers) { @@ -2385,11 +3006,7 @@ ${hookUniformFields}} let [_, params, body] = /^(\([^\)]*\))((?:.|\n)*)$/.exec(shader.hooks[shaderType][hookDef]); - if (shaderType !== 'fragment') { - // Splice the instance ID in as a final parameter to every WGSL hook function - let hasParams = !!params.match(/^\(\s*\S+.*\)$/); - params = params.slice(0, -1) + (hasParams ? ', ' : '') + 'instanceID: u32)'; - } + params = appendHookParams(params, hookExtraParams); if (hookType === 'void') { hooks += `fn HOOK_${hookName}${params}${body}\n`; @@ -2398,40 +3015,45 @@ ${hookUniformFields}} } } - // Add the instance ID as a final parameter to each hook call - if (shaderType !== 'fragment') { - const addInstanceIDParam = (src) => { - let result = src; - let idx = 0; - let match; - do { - match = /HOOK_\w+\(/.exec(result.slice(idx)); - if (match) { - idx += match.index + match[0].length - 1; - let nesting = 0; - let hasParams = false; - while (idx < result.length) { - if (result[idx] === '(') { - nesting++; - } else if (result[idx] === ')') { - nesting--; - } else if (result[idx].match(/\S/)) { - hasParams = true; - } - idx++; - if (nesting === 0) { - break; - } + // Pass stage-specific builtins from main to each hook call. + // Collect ALL HOOK_ calls (including nested ones) then insert + // extra args from right to left so position shifts don't + // invalidate earlier insertion points. + if (hookExtraArgs.length > 0) { + const addHookArgs = (src) => { + const insertions = []; + let searchIdx = 0; + let m; + while ((m = /HOOK_\w+\(/.exec(src.slice(searchIdx))) !== null) { + const openParen = searchIdx + m.index + m[0].length - 1; + let pos = openParen + 1; + let nesting = 1; + let hasParams = false; + while (pos < src.length && nesting > 0) { + if (src[pos] === '(') nesting++; + else if (src[pos] === ')') { + nesting--; + if (nesting === 0) break; + } else if (/\S/.test(src[pos])) { + hasParams = true; } - const insertion = (hasParams ? ', ' : '') + 'instanceID'; - result = result.slice(0, idx-1) + insertion + result.slice(idx-1); - idx += insertion.length; + pos++; } - } while (match); + insertions.push({ pos, hasParams }); + searchIdx = openParen + 1; + } + + insertions.sort((a, b) => b.pos - a.pos); + + let result = src; + for (const { pos, hasParams } of insertions) { + const insertion = (hasParams ? ', ' : '') + hookExtraArgs.join(', '); + result = result.slice(0, pos) + insertion + result.slice(pos); + } return result; }; - preMain = addInstanceIDParam(preMain); - postMain = addInstanceIDParam(postMain); + preMain = addHookArgs(preMain); + postMain = addHookArgs(postMain); } return preMain + '\n' + defines + hooks + main + postMain; @@ -2490,6 +3112,10 @@ ${hookUniformFields}} body = shader.hooks.fragment[hookName]; fullSrc = shader._fragSrc; } + if (!body) { + body = shader.hooks.compute[hookName]; + fullSrc = shader._computeSrc; + } if (!body) { throw new Error(`Can't find hook ${hookName}!`); } @@ -2621,7 +3247,7 @@ ${hookUniformFields}} } defaultFramebufferAntialias() { - return true; + return this._pInst._webgpuAttributes?.antialias !== false; } supportsFramebufferAntialias() { @@ -2814,6 +3440,267 @@ ${hookUniformFields}} }; } + // Maps a plain JS value to the WGSL type string that represents it in a struct. + _jsValueToWgslType(value) { + if (typeof value === 'number') return 'f32'; + // Duck typing instead of instanceof to avoid importing a separate + // copy of the Color/Vector classes + if (value?.isVector) { + if (value.dimensions === 2) return 'vec2f'; + if (value.dimensions === 3) return 'vec3f'; + if (value.dimensions === 4) return 'vec4f'; + throw new Error(`Unsupported vector dimension ${value.dimensions} for struct storage field`); + } + if (value?.isColor) { + return 'vec4f'; + } + if (Array.isArray(value)) { + if (value.length === 2) return 'vec2f'; + if (value.length === 3) return 'vec3f'; + if (value.length === 4) return 'vec4f'; + throw new Error(`Unsupported array length ${value.length} for struct storage field`); + } + throw new Error(`Unsupported value type ${typeof value} for struct storage field`); + } + + // Infers a struct schema from the first element of a struct array. + // + // Returns { fields, stride, structBody } where: + // fields: field has the _packField interface (baseType, size, offset, packInPlace) plus: + // name: string - JS property name + // dim: number - float component count, used when creating StrandsNodes + // structBody: everything inside the { ... } of a WGSL struct definition + // stride: how many bytes are reserved for this struct in the buffer + _inferStructSchema(firstElement) { + const entries = Object.entries(firstElement); + + if (!p5.disableFriendlyErrors) { + for (const [name, value] of entries) { + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + // Duck typing instead of instanceof to avoid importing a separate + // copy of the Color/Vector classes + !value?.isVector && + !value?.isColor + ) { + p5._friendlyError( + `The "${name}" property in your storage data contains a nested object. ` + + `Make sure you only use properties with numbers, arrays of numbers, or p5.Vector.`, + 'createStorage' + ); + } + } + } + + const fieldLines = entries.map(([name, value]) => + ` ${name}: ${this._jsValueToWgslType(value)},` + ).join('\n'); + const structBody = `{\n${fieldLines}\n}`; + const elements = this._parseStruct(`struct _Tmp ${structBody}`, '_Tmp'); + + let maxEnd = 0; + let maxAlign = 1; + const fields = entries.map(([name, value]) => { + const el = elements[name]; + maxEnd = Math.max(maxEnd, el.offsetEnd); + // Alignment for scalars/vectors: <=4 -> 4, <=8 -> 8, else 16 + const align = el.size <= 4 ? 4 : el.size <= 8 ? 8 : 16; + maxAlign = Math.max(maxAlign, align); + // Track original JS type for reconstruction during readback + const kind = value?.isVector ? 'vector' + : value?.isColor ? 'color' + : undefined; + return { + name, + baseType: el.baseType, + size: el.size, + offset: el.offset, + packInPlace: el.packInPlace ?? false, + dim: el.size / 4, + kind, + }; + }); + + const stride = Math.ceil(maxEnd / maxAlign) * maxAlign; + return { fields, stride, structBody }; + } + + // Packs an array of plain objects into a Float32Array using the given struct schema. + // Reuses _packField so layout rules match uniform packing exactly. + _packStructArray(data, schema) { + const { fields, stride } = schema; + const totalBytes = Math.max(data.length * stride, 16); + const alignedBytes = Math.ceil(totalBytes / 16) * 16; + const buffer = new ArrayBuffer(alignedBytes); + const floatView = new Float32Array(buffer); + const dataView = new DataView(buffer); + for (let i = 0; i < data.length; i++) { + const item = data[i]; + const baseOffset = i * stride; + for (const field of fields) { + this._packField(field, item[field.name], floatView, dataView, baseOffset); + } + } + return floatView; + } + + // Inverse of _packStructArray reads packed buffer back into plain JS objects + // using the same schema layout - fields, stride and offsets + _unpackStructArray(floatView, schema) { + const { fields, stride } = schema; + const dataView = new DataView(floatView.buffer); + const count = Math.floor(floatView.byteLength / stride); + const result = []; + + for (let i = 0; i < count; i++) { + const item = {}; + const baseOffset = i * stride; + for (const field of fields) { + const byteOffset = baseOffset + field.offset; + const n = field.size / 4; + + if (field.baseType === 'u32') { + if (n === 1) { + item[field.name] = dataView.getUint32(byteOffset, true); + } else { + item[field.name] = Array.from({ length: n }, (_, j) => + dataView.getUint32(byteOffset + j * 4, true) + ); + } + } else if (field.baseType === 'i32') { + if (n === 1) { + item[field.name] = dataView.getInt32(byteOffset, true); + } else { + item[field.name] = Array.from({ length: n }, (_, j) => + dataView.getInt32(byteOffset + j * 4, true) + ); + } + } else { + const idx = byteOffset / 4; + if (n === 1) { + item[field.name] = floatView[idx]; + } else { + const values = Array.from(floatView.slice(idx, idx + n)); + if (field.kind === 'vector') { + item[field.name] = this._pInst.createVector(...values); + } else if (field.kind === 'color') { + // Color was packed as normalized RGBA [0-1] via _getRGBA([1,1,1,1]) + // Scale back to the current colorMode range + const maxes = this.states.colorMaxes[this.states.colorMode]; + item[field.name] = this._pInst.color( + values[0] * maxes[0], values[1] * maxes[1], + values[2] * maxes[2], values[3] * maxes[3] + ); + } else { + item[field.name] = values; + } + } + } + } + result.push(item); + } + + return result; + } + + createStorage(dataOrCount) { + const device = this.device; + + // Struct array: an array of plain objects + if (Array.isArray(dataOrCount) && dataOrCount.length > 0 && + typeof dataOrCount[0] === 'object' && !Array.isArray(dataOrCount[0])) { + if (!p5.disableFriendlyErrors && dataOrCount.length > 1) { + const firstKeys = Object.keys(dataOrCount[0]); + let warned = false; + for (let i = 1; i < dataOrCount.length; i++) { + const el = dataOrCount[i]; + const elKeys = Object.keys(el); + const sameKeys = firstKeys.length === elKeys.length && + firstKeys.every((k, j) => k === elKeys[j]); + if (!sameKeys) { + p5._friendlyError( + `Element ${i} has different fields than element 0. ` + + `All elements should have the same properties.`, + 'createStorage' + ); + break; + } + for (const key of firstKeys) { + const firstType = this._jsValueToWgslType(dataOrCount[0][key]); + const elType = this._jsValueToWgslType(el[key]); + if (firstType !== elType) { + p5._friendlyError( + `The "${key}" property of element ${i} has type ${elType} ` + + `but element 0 has type ${firstType}. Proporties should have the same type across all elements.`, + 'createStorage' + ); + warned = true; + break; + } + } + if (warned) break; + } + } + const schema = this._inferStructSchema(dataOrCount[0]); + const packed = this._packStructArray(dataOrCount, schema); + const size = packed.byteLength; + const buffer = device.createBuffer({ + size, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + mappedAtCreation: true, + }); + new Float32Array(buffer.getMappedRange()).set(packed); + buffer.unmap(); + const storageBuffer = new StorageBuffer(buffer, size, this, schema); + this._storageBuffers.add(storageBuffer); + return storageBuffer; + } + + // Determine buffer size and initial data + let size, initialData; + if (typeof dataOrCount === 'number') { + // createStorage(count) - zero-initialized + size = dataOrCount * 4; // floats are 4 bytes + initialData = new Float32Array(dataOrCount); + } else { + // createStorage(array) - from data + if (dataOrCount instanceof Float32Array) { + initialData = dataOrCount; + } else if (Array.isArray(dataOrCount)) { + initialData = new Float32Array(dataOrCount); + } else { + throw new Error('createStorage expects a number or array/Float32Array'); + } + size = initialData.byteLength; + } + + // Align to 16 bytes (WGSL storage buffer alignment requirement) + size = Math.ceil(size / 16) * 16; + + // Create storage buffer with STORAGE | COPY_DST | COPY_SRC usage + const buffer = device.createBuffer({ + size, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + mappedAtCreation: initialData.length > 0 + }); + + // Write initial data if provided + if (initialData.length > 0) { + const mapping = new Float32Array(buffer.getMappedRange()); + mapping.set(initialData); + buffer.unmap(); + } + + const storageBuffer = new StorageBuffer(buffer, size, this); + + // Track for cleanup + this._storageBuffers.add(storageBuffer); + + return storageBuffer; + } + _getWebGPUColorFormat(framebuffer) { if (framebuffer.format === constants.FLOAT) { return framebuffer.channels === RGBA ? 'rgba32float' : 'rgba32float'; @@ -3110,10 +3997,6 @@ ${hookUniformFields}} return super.filter(...args); } - getNoiseShaderSnippet() { - return noiseWGSL; - } - baseFilterShader() { if (!this._baseFilterShader) { @@ -3137,6 +4020,21 @@ ${hookUniformFields}} return this._baseFilterShader; } + baseComputeShader() { + if (!this._baseComputeShader) { + this._baseComputeShader = new Shader( + this, + baseComputeShader, + { + compute: { + 'void iteration': '(index: vec3) {}', + }, + } + ); + } + return this._baseComputeShader; + } + /* * WebGPU-specific implementation of imageLight shader creation */ @@ -3236,6 +4134,69 @@ ${hookUniformFields}} glDataType: dataType || 'uint8' }; } + + compute(shader, x, y = 1, z = 1) { + if (shader.shaderType !== 'compute') { + throw new Error('compute() can only be called with a compute shader'); + } + + this._finishActiveRenderPass(); + + // Ensure shader is initialized and finalized + if (!shader._compiled) { + shader.init(); + } + + // Set default uniforms + shader.setDefaultUniforms(); + shader.setUniform('uTotalCount', [x, y, z]); + + // Calculate optimal workgroup size (8x8x1 = 64 threads per workgroup) + const WORKGROUP_SIZE_X = 8; + const WORKGROUP_SIZE_Y = 8; + const WORKGROUP_SIZE_Z = 1; + + // auto spreading: if any dimension is too large or for performance optimization, + // spread total iteration count across dimensions + const totalIterations = x * y * z; + const MAX_THREADS_PER_DIM = 65535 * 8; + + let px = x; + let py = y; + let pz = z; + + // we spread if we exceed GPU limits OR if it involves a large 1D dispatch + const exceedsLimits = x > MAX_THREADS_PER_DIM || y > MAX_THREADS_PER_DIM || z > MAX_THREADS_PER_DIM; + const isLarge1D = totalIterations > 1024 && y === 1 && z === 1; + + if (exceedsLimits || isLarge1D) { + // Always use 2D square spreading (√N Γ— √N). + // Benchmarks showed 2D square equals or outperforms 3D cube at every + // scale tested, with simpler index reconstruction in the shader. + px = Math.ceil(Math.sqrt(totalIterations)); + py = Math.ceil(totalIterations / px); + pz = 1; + } + + shader.setUniform('uPhysicalCount', [px, py, pz]); + + const workgroupCountX = Math.ceil(px / WORKGROUP_SIZE_X); + const workgroupCountY = Math.ceil(py / WORKGROUP_SIZE_Y); + const workgroupCountZ = Math.ceil(pz / WORKGROUP_SIZE_Z); + + const commandEncoder = this.device.createCommandEncoder(); + const passEncoder = commandEncoder.beginComputePass(); + this.setupShaderBindGroups(shader, passEncoder, { + compute: true, + workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y, WORKGROUP_SIZE_Z], + }); + + // Dispatch compute workgroups + passEncoder.dispatchWorkgroups(workgroupCountX, workgroupCountY, workgroupCountZ); + + passEncoder.end(); + this.device.queue.submit([commandEncoder.finish()]); + } } p5.RendererWebGPU = RendererWebGPU; @@ -3246,6 +4207,7 @@ ${hookUniformFields}} fn.setAttributes = async function (key, value) { return this._renderer._setAttributes(key, value); } + } export default rendererWebGPU; diff --git a/src/webgpu/shaders/color.js b/src/webgpu/shaders/color.js index cea80efc9b..cf4ab00285 100644 --- a/src/webgpu/shaders/color.js +++ b/src/webgpu/shaders/color.js @@ -119,7 +119,7 @@ ${uniforms} @fragment fn main(input: FragmentInput) -> @location(0) vec4 { HOOK_beforeFragment(); - var outColor = HOOK_getFinalColor(input.vColor); + var outColor = HOOK_getFinalColor(input.vColor, input.vVertTexCoord); outColor = vec4(outColor.rgb * outColor.a, outColor.a); HOOK_afterFragment(); return outColor; diff --git a/src/webgpu/shaders/compute.js b/src/webgpu/shaders/compute.js new file mode 100644 index 0000000000..dafe356ee6 --- /dev/null +++ b/src/webgpu/shaders/compute.js @@ -0,0 +1,30 @@ +export const baseComputeShader = ` +struct ComputeUniforms { + uTotalCount: vec3, + uPhysicalCount: vec3, +} +@group(0) @binding(0) var uniforms: ComputeUniforms; + +@compute @workgroup_size(8, 8, 1) +fn main( + @builtin(global_invocation_id) globalId: vec3, + @builtin(local_invocation_id) localId: vec3, + @builtin(workgroup_id) workgroupId: vec3, + @builtin(local_invocation_index) localIndex: u32 +) { + let totalIterations = u32(uniforms.uTotalCount.x) * u32(uniforms.uTotalCount.y) * u32(uniforms.uTotalCount.z); + let physicalId = globalId.x + globalId.y * (u32(uniforms.uPhysicalCount.x)) + globalId.z * (u32(uniforms.uPhysicalCount.x) * u32(uniforms.uPhysicalCount.y)); + + if (physicalId >= totalIterations) { + return; + } + + var index = vec3(0); + index.x = i32(physicalId % u32(uniforms.uTotalCount.x)); + let remainingY = physicalId / u32(uniforms.uTotalCount.x); + index.y = i32(remainingY % u32(uniforms.uTotalCount.y)); + index.z = i32(remainingY / u32(uniforms.uTotalCount.y)); + + HOOK_iteration(index); +} +`; diff --git a/src/webgpu/shaders/functions/randomComputeWGSL.js b/src/webgpu/shaders/functions/randomComputeWGSL.js new file mode 100644 index 0000000000..321ed4640a --- /dev/null +++ b/src/webgpu/shaders/functions/randomComputeWGSL.js @@ -0,0 +1,29 @@ +// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW) +// Mixing constants: Rβ‚‚ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/) +// α₁ = 1/Ο†β‚‚ = 0.7548776662 (plastic constant reciprocal) +// Ξ±β‚‚ = 1/Ο†β‚‚Β² = 0.5698402910 +// 1/Ο† = 0.6180339887 (golden ratio conjugate) +// +// Compute shader version: invocationId is passed in from main via @builtin(global_invocation_id). + +export default ` +var _p5_randomCallIndex: i32 = 0; + +fn _p5_hash(p: vec3) -> f32 { + var p3 = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p3 = p3 + dot(p3, p3.yxz + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn random(seed: f32, invocationId: vec3) -> f32 { + let id = vec3(invocationId); + let callIndex = f32(_p5_randomCallIndex); + _p5_randomCallIndex = _p5_randomCallIndex + 1; + let s = fract(seed * 0.7548776662); + return _p5_hash(vec3( + id.x + s, + id.y + callIndex * 0.5698402910, + id.z + s + callIndex * 0.6180339887 + )); +} +`; diff --git a/src/webgpu/shaders/functions/randomVertWGSL.js b/src/webgpu/shaders/functions/randomVertWGSL.js new file mode 100644 index 0000000000..210d6c49c8 --- /dev/null +++ b/src/webgpu/shaders/functions/randomVertWGSL.js @@ -0,0 +1,28 @@ +// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW) +// Mixing constants: Rβ‚‚ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/) +// α₁ = 1/Ο†β‚‚ = 0.7548776662 (plastic constant reciprocal) +// Ξ±β‚‚ = 1/Ο†β‚‚Β² = 0.5698402910 +// 1/Ο† = 0.6180339887 (golden ratio conjugate) +// +// Vertex shader version: vertexId is passed in from main via @builtin(vertex_index). + +export default ` +var _p5_randomCallIndex: i32 = 0; + +fn _p5_hash(p: vec3) -> f32 { + var p3 = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p3 = p3 + dot(p3, p3.yxz + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn random(seed: f32, vertexId: f32) -> f32 { + let callIndex = f32(_p5_randomCallIndex); + _p5_randomCallIndex = _p5_randomCallIndex + 1; + let s = fract(seed * 0.7548776662); + return _p5_hash(vec3( + vertexId + s, + vertexId * 0.5698402910 + callIndex * 0.6180339887, + s + callIndex * 0.7548776662 + )); +} +`; diff --git a/src/webgpu/shaders/functions/randomWGSL.js b/src/webgpu/shaders/functions/randomWGSL.js new file mode 100644 index 0000000000..62005bd2a4 --- /dev/null +++ b/src/webgpu/shaders/functions/randomWGSL.js @@ -0,0 +1,28 @@ +// _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW) +// Mixing constants: Rβ‚‚ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/) +// α₁ = 1/Ο†β‚‚ = 0.7548776662 (plastic constant reciprocal) +// Ξ±β‚‚ = 1/Ο†β‚‚Β² = 0.5698402910 +// 1/Ο† = 0.6180339887 (golden ratio conjugate) +// +// Fragment shader version: pixelCoord is passed in from main via @builtin(position). + +export default ` +var _p5_randomCallIndex: i32 = 0; + +fn _p5_hash(p: vec3) -> f32 { + var p3 = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p3 = p3 + dot(p3, p3.yxz + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn random(seed: f32, pixelCoord: vec2) -> f32 { + let callIndex = f32(_p5_randomCallIndex); + _p5_randomCallIndex = _p5_randomCallIndex + 1; + let s = fract(seed * 0.7548776662); + return _p5_hash(vec3( + pixelCoord.x + s, + pixelCoord.y + callIndex * 0.5698402910, + s + callIndex * 0.6180339887 + )); +} +`; diff --git a/src/webgpu/shaders/line.js b/src/webgpu/shaders/line.js index a46317cee6..2b58857286 100644 --- a/src/webgpu/shaders/line.js +++ b/src/webgpu/shaders/line.js @@ -362,7 +362,7 @@ fn main(input: StrokeFragmentInput) -> @location(0) vec4 { discard; } } - var col = HOOK_getFinalColor(inputs.color); + var col = HOOK_getFinalColor(inputs.color, vec2(0.0, 0.0)); col = vec4(col.rgb, 1.0) * col.a; HOOK_afterFragment(); return vec4(col); diff --git a/src/webgpu/shaders/material.js b/src/webgpu/shaders/material.js index a740e9d17a..751ec0ad3a 100644 --- a/src/webgpu/shaders/material.js +++ b/src/webgpu/shaders/material.js @@ -416,9 +416,9 @@ fn main(input: FragmentInput) -> @location(0) vec4 { inputs.emissiveMaterial ); - var outColor = HOOK_getFinalColor( - HOOK_combineColors(components) - ); + var outColor = HOOK_getFinalColor( + HOOK_combineColors(components), input.vTexCoord + ); outColor = vec4(outColor.rgb * outColor.a, outColor.a); HOOK_afterFragment(); return outColor; diff --git a/src/webgpu/strands_wgslBackend.js b/src/webgpu/strands_wgslBackend.js index 4394210414..79d0f2816b 100644 --- a/src/webgpu/strands_wgslBackend.js +++ b/src/webgpu/strands_wgslBackend.js @@ -1,4 +1,8 @@ -import { NodeType, OpCodeToSymbol, BlockType, OpCode, NodeTypeToName, isStructType, BaseType, StatementType, DataType } from "../strands/ir_types"; +import noiseWGSL from './shaders/functions/noise3DWGSL.js'; +import randomWGSL from './shaders/functions/randomWGSL'; +import randomVertWGSL from './shaders/functions/randomVertWGSL'; +import randomComputeWGSL from './shaders/functions/randomComputeWGSL'; +import { NodeType, OpCodeToSymbol, BlockType, OpCode, NodeTypeToName, isStructType, BaseType, StatementType, DataType, INSTANCE_ID_VARYING_NAME, HOOK_PARAM_PREFIX } from "../strands/ir_types"; import { getNodeDataFromID, extractNodeTypeInfo } from "../strands/ir_dag"; import * as FES from '../strands/strands_FES'; import * as build from '../strands/ir_builders'; @@ -186,16 +190,16 @@ export const wgslBackend = { hookEntry(hookType) { const params = hookType.parameters.map((param) => { // For struct types, use a raw prefix since we'll create a mutable copy - const paramName = param.type.properties ? `_p5_strands_raw_${param.name}` : param.name; + const paramName = param.type.properties ? `_p5_strands_raw_${param.name}` : `${HOOK_PARAM_PREFIX}${param.name}`; return `${paramName}: ${param.type.typeName}`; }).join(', '); const firstLine = `(${params}) {`; - // Generate mutable copies for struct parameters with original names + // Generate mutable copies for struct parameters const mutableCopies = hookType.parameters .filter(param => param.type.properties) // Only struct types - .map(param => ` var ${param.name} = _p5_strands_raw_${param.name};`) + .map(param => ` var ${HOOK_PARAM_PREFIX}${param.name} = _p5_strands_raw_${param.name};`) .join('\n'); return mutableCopies ? firstLine + '\n' + mutableCopies : firstLine; @@ -204,10 +208,10 @@ export const wgslBackend = { // Add texture and sampler bindings for sampler2D uniforms to both vertex and fragment declarations if (!strandsContext.renderer || !strandsContext.baseShader) return; - // Get the next available binding index from the renderer let bindingIndex = strandsContext.renderer.getNextBindingIndex({ - vert: strandsContext.baseShader.vertSrc(), - frag: strandsContext.baseShader.fragSrc(), + vert: strandsContext.baseShader._vertSrc, + frag: strandsContext.baseShader._fragSrc, + compute: strandsContext.baseShader._computeSrc, }); for (const {name, typeInfo} of strandsContext.uniforms) { @@ -224,6 +228,38 @@ export const wgslBackend = { } } }, + addStorageBufferBindingsToDeclarations(strandsContext) { + if (!strandsContext.renderer || !strandsContext.baseShader) return; + + const isComputeShader = strandsContext.baseShader.shaderType === 'compute'; + let bindingIndex = strandsContext.renderer.getNextBindingIndex({ + vert: strandsContext.baseShader._vertSrc, + frag: strandsContext.baseShader._fragSrc, + compute: strandsContext.baseShader._computeSrc, + }); + + for (const {name, typeInfo} of strandsContext.uniforms) { + if (typeInfo.baseType === 'storage') { + const accessMode = isComputeShader ? 'read_write' : 'read'; + let declaration; + if (typeInfo.schema) { + const structTypeName = `${name}Element`; + declaration = `struct ${structTypeName} ${typeInfo.schema.structBody}\n@group(0) @binding(${bindingIndex}) var ${name}: array<${structTypeName}>;`; + } else { + declaration = `@group(0) @binding(${bindingIndex}) var ${name}: array;`; + } + + if (isComputeShader) { + strandsContext.computeDeclarations.add(declaration); + } else { + strandsContext.vertexDeclarations.add(declaration); + strandsContext.fragmentDeclarations.add(declaration); + } + + bindingIndex += 1; + } + } + }, getTypeName(baseType, dimension) { const primitiveTypeName = TypeNames[baseType + dimension] if (!primitiveTypeName) { @@ -231,6 +267,19 @@ export const wgslBackend = { } return primitiveTypeName; }, + getNoiseShaderSnippet() { + return noiseWGSL; + }, + getRandomFragmentShaderSnippet() { + return randomWGSL; + }, + getRandomVertexShaderSnippet() { + return randomVertWGSL; + }, + getRandomComputeShaderSnippet() { + return randomComputeWGSL; + }, + generateHookUniformKey(name, typeInfo) { // For sampler2D types, we don't add them to the uniform struct, // but we still need them in the shader's hooks object so that @@ -238,6 +287,11 @@ export const wgslBackend = { if (typeInfo.baseType === 'sampler2D') { return `${name}: sampler2D`; // Signal that this should not be added to uniform struct } + // For storage buffers, we don't add them to the uniform struct + // Instead, they become separate storage buffer bindings + if (typeInfo.baseType === 'storage') { + return null; // Signal that this should not be added to uniform struct + } return `${name}: ${this.getTypeName(typeInfo.baseType, typeInfo.dimension)}`; }, generateVaryingVariable(varName, typeInfo) { @@ -264,9 +318,13 @@ export const wgslBackend = { // Generate just a semicolon (unless suppressed) generationContext.write(semicolon); } else if (node.statementType === StatementType.EARLY_RETURN) { - const exprNodeID = node.dependsOn[0]; - const expr = this.generateExpression(generationContext, dag, exprNodeID); - generationContext.write(`return ${expr}${semicolon}`); + if (node.dependsOn && node.dependsOn.length > 0) { + const exprNodeID = node.dependsOn[0]; + const expr = this.generateExpression(generationContext, dag, exprNodeID); + generationContext.write(`return ${expr}${semicolon}`); + } else { + generationContext.write(`return${semicolon}`); + } } }, generateAssignment(generationContext, dag, nodeID) { @@ -278,6 +336,17 @@ export const wgslBackend = { const targetNode = getNodeDataFromID(dag, targetNodeID); const semicolon = generationContext.suppressSemicolon ? '' : ';'; + // Check if target is an array access (storage buffer assignment) + if (targetNode.opCode === OpCode.Binary.ARRAY_ACCESS) { + const [bufferID, indexID] = targetNode.dependsOn; + const bufferExpr = this.generateExpression(generationContext, dag, bufferID); + const indexExpr = this.generateExpression(generationContext, dag, indexID); + const sourceExpr = this.generateExpression(generationContext, dag, sourceNodeID); + const fieldSuffix = targetNode.identifier ? `.${targetNode.identifier}` : ''; + generationContext.write(`${bufferExpr}[i32(${indexExpr})]${fieldSuffix} = ${sourceExpr}${semicolon}`); + return; + } + // Check if target is a swizzle assignment if (targetNode.opCode === OpCode.Unary.SWIZZLE) { const parentID = targetNode.dependsOn[0]; @@ -335,6 +404,10 @@ export const wgslBackend = { return `var ${tmp}: ${typeName} = ${expr};`; }, generateReturnStatement(strandsContext, generationContext, rootNodeID, returnType) { + if (!returnType) { + generationContext.write('return;'); + return; + } const dag = strandsContext.dag; const rootNode = getNodeDataFromID(dag, rootNodeID); if (isStructType(returnType)) { @@ -375,9 +448,15 @@ export const wgslBackend = { } } - // Check if this is a uniform variable (but not a texture) + // Detect instanceID usage in fragment context and rewrite to varying name + if (node.identifier === this.instanceIdReference() && generationContext.shaderContext === 'fragment') { + generationContext.strandsContext._instanceIDUsedInFragment = true; + return INSTANCE_ID_VARYING_NAME; + } + + // Check if this is a uniform variable (but not a texture or storage buffer) const uniform = generationContext.strandsContext?.uniforms?.find(uniform => uniform.name === node.identifier); - if (uniform && uniform.typeInfo.baseType !== 'sampler2D') { + if (uniform && uniform.typeInfo.baseType !== 'sampler2D' && uniform.typeInfo.baseType !== 'storage') { return `hooks.${node.identifier}`; } @@ -396,6 +475,13 @@ export const wgslBackend = { const deps = node.dependsOn.map((dep) => this.generateExpression(generationContext, dag, dep)); return `${T}(${deps.join(', ')})`; } + if (node.opCode === OpCode.Nary.TERNARY) { + const [condID, trueID, falseID] = node.dependsOn; + const cond = this.generateExpression(generationContext, dag, condID); + const trueExpr = this.generateExpression(generationContext, dag, trueID); + const falseExpr = this.generateExpression(generationContext, dag, falseID); + return `select(${falseExpr}, ${trueExpr}, ${cond})`; + } if (node.opCode === OpCode.Nary.FUNCTION_CALL) { // Convert mod() function calls to % operator in WGSL if (node.identifier === 'mod' && node.dependsOn.length === 2) { @@ -417,6 +503,18 @@ export const wgslBackend = { } const functionArgs = node.dependsOn.map(arg =>this.generateExpression(generationContext, dag, arg)); + + if (node.identifier === 'random') { + const ctx = generationContext.shaderContext; + if (ctx === 'fragment') { + functionArgs.push('_p5FragPos.xy'); + } else if (ctx === 'vertex') { + functionArgs.push('f32(_p5VertexId)'); + } else if (ctx === 'compute') { + functionArgs.push('_p5GlobalId'); + } + } + return `${node.identifier}(${functionArgs.join(', ')})`; } if (node.opCode === OpCode.Binary.MEMBER_ACCESS) { @@ -430,6 +528,13 @@ export const wgslBackend = { const parentExpr = this.generateExpression(generationContext, dag, parentID); return `${parentExpr}.${node.swizzle}`; } + if (node.opCode === OpCode.Binary.ARRAY_ACCESS) { + const [bufferID, indexID] = node.dependsOn; + const bufferExpr = this.generateExpression(generationContext, dag, bufferID); + const indexExpr = this.generateExpression(generationContext, dag, indexID); + const fieldSuffix = node.identifier ? `.${node.identifier}` : ''; + return `${bufferExpr}[i32(${indexExpr})]${fieldSuffix}`; + } if (node.dependsOn.length === 2) { const [lID, rID] = node.dependsOn; const left = this.generateExpression(generationContext, dag, lID); @@ -503,12 +608,25 @@ export const wgslBackend = { const samplerVariable = build.variableNode(strandsContext, { baseType: BaseType.SAMPLER, dimension: 1 }, samplerIdentifier); const samplerNode = createStrandsNode(samplerVariable.id, samplerVariable.dimension, strandsContext); - // Create the augmented args: [texture, sampler, coords] - const augmentedArgs = [textureArg, samplerNode, coordsArg]; - - const { id, dimension } = build.functionCallNode(strandsContext, 'textureSample', augmentedArgs, { + // Create a LOD literal node (0.0) so we can use textureSampleLevel instead + // of textureSample. textureSample doesn't let you use uniform values in control + // flow, whereas textureSampleLevel does. While we don't have mipmaps, we don't + // miss out. + // TODO: if we *do* add mipmap support, update this logic -- we'd need to hoist + // the texture lookup out of the control flow. + const lodLiteral = build.scalarLiteralNode( + strandsContext, + { dimension: 1, baseType: BaseType.FLOAT }, + 0.0 + ); + const lodNode = createStrandsNode(lodLiteral.id, lodLiteral.dimension, strandsContext); + + // Create the augmented args: [texture, sampler, coords, lod] + const augmentedArgs = [textureArg, samplerNode, coordsArg, lodNode]; + + const { id, dimension } = build.functionCallNode(strandsContext, 'textureSampleLevel', augmentedArgs, { overloads: [{ - params: [DataType.sampler2D, DataType.sampler, DataType.float2], + params: [DataType.sampler2D, DataType.sampler, DataType.float2, DataType.float1], returnType: DataType.float4 }] }); @@ -518,4 +636,8 @@ export const wgslBackend = { instanceIdReference() { return 'instanceID'; }, + + generateInstanceIDVarying() { + return { name: INSTANCE_ID_VARYING_NAME, declaration: `${INSTANCE_ID_VARYING_NAME}: i32`, source: 'i32(instanceID)', interpolation: 'flat' }; + }, } diff --git a/test/bench/vectors.bench.js b/test/bench/vectors.bench.js new file mode 100644 index 0000000000..952c428509 --- /dev/null +++ b/test/bench/vectors.bench.js @@ -0,0 +1,120 @@ +import { Vector } from '../../src/math/p5.Vector.js'; + +import { bench, describe } from "vitest"; + + +describe("vector operations", () => { + + bench( + "mult 5", + () => { + const nLimited = 5; + // TODO try just operating on det. values based on i and j + // without re-creation + + for (let i = 0; i < nLimited; i++) { + for (let j = 0; j < nLimited; j++) { + const tmp = arr[i].mult(arr[j]); + } + } + } + ); + + bench( + "mult 10", + () => { + const nLimited = 10; + const arr = setupVectors(); + + for (let i = 0; i < nLimited; i++) { + for (let j = 0; j < nLimited; j++) { + const tmp = arr[i].mult(arr[j]); + } + } + } + ); + + bench( + "mult 20", + () => { + const nLimited = 20; + const arr = setupVectors(); + + for (let i = 0; i < nLimited; i++) { + for (let j = 0; j < nLimited; j++) { + const tmp = arr[i].mult(arr[j]); + } + } + } + ); + bench( + "mult 100", + () => { + const nLimited = 100; + const arr = setupVectors(); + + for (let i = 0; i < nLimited; i++) { + for (let j = 0; j < nLimited; j++) { + const tmp = arr[i].mult(arr[j]); + } + } + } + ); + + + + bench( + "add 5", + () => { + const nLimited = 5; + const arr = setupVectors(); + + for (let i = 0; i < nLimited; i++) { + for (let j = 0; j < nLimited; j++) { + const tmp = arr[i].add(arr[j]); + } + } + } + ); + + bench( + "add 10", + () => { + const nLimited = 10; + const arr = setupVectors(); + + for (let i = 0; i < nLimited; i++) { + for (let j = 0; j < nLimited; j++) { + const tmp = arr[i].add(arr[j]); + } + } + }); + + bench( + "add 20", + () => { + const nLimited = 20; + const arr = setupVectors(); + + for (let i = 0; i < nLimited; i++) { + for (let j = 0; j < nLimited; j++) { + const tmp = arr[i].add(arr[j]); + } + } + } + ); + bench( + "add 100", + () => { + const nLimited = 100; + const arr = setupVectors(); + + for (let i = 0; i < nLimited; i++) { + for (let j = 0; j < nLimited; j++) { + const tmp = arr[i].add(arr[j]); + } + } + }, + ); + +}); diff --git a/test/types/webgpu.ts b/test/types/webgpu.ts new file mode 100644 index 0000000000..7c9016cdf1 --- /dev/null +++ b/test/types/webgpu.ts @@ -0,0 +1,9 @@ +import p5 from '../../types/global' + +async function setup() { + const renderer: p5.Renderer = await createCanvas(100, 100, WEBGPU); + background(0); + fill(255); + noStroke(); + circle(0, 0, 50); +} diff --git a/test/unit/core/States.js b/test/unit/core/States.js new file mode 100644 index 0000000000..d4b2b56a35 --- /dev/null +++ b/test/unit/core/States.js @@ -0,0 +1,70 @@ +import { States } from '../../../src/core/States.js'; + +suite('States', function () { + test('initialises with provided state', function () { + const s = new States({ fill: 'red', stroke: 'blue' }); + assert.equal(s.fill, 'red'); + assert.equal(s.stroke, 'blue'); + }); + + test('setValue() updates the value', function () { + const s = new States({ fill: 'red' }); + s.setValue('fill', 'green'); + assert.equal(s.fill, 'green'); + }); + + test('setValue() records the original before first modification', function () { + const s = new States({ fill: 'red' }); + s.setValue('fill', 'green'); + assert.equal(s.getModified().fill, 'red'); + }); + + test('setValue() does not overwrite original on repeated calls', function () { + const s = new States({ fill: 'red' }); + s.setValue('fill', 'green'); + s.setValue('fill', 'blue'); + assert.equal(s.getModified().fill, 'red'); + }); + + test('takeDiff() returns the modified map', function () { + const s = new States({ fill: 'red' }); + s.setValue('fill', 'green'); + const diff = s.takeDiff(); + assert.equal(diff.fill, 'red'); + }); + + test('takeDiff() clears #modified after returning', function () { + const s = new States({ fill: 'red' }); + s.setValue('fill', 'green'); + s.takeDiff(); + assert.deepEqual(s.takeDiff(), {}); + }); + + test('applyDiff() undoes current modifications and replaces #modified', function () { + const s = new States({ fill: 'red' }); + s.setValue('fill', 'green'); + const outerModified = {}; + s.applyDiff(outerModified); + assert.equal(s.fill, 'red'); + assert.deepEqual(s.getModified(), {}); + }); + + test('applyDiff() with a non-empty prevModified replaces #modified', function () { + const s = new States({ fill: 'red', stroke: 'black' }); + s.setValue('fill', 'green'); + const outerModified = { stroke: 'black' }; + s.applyDiff(outerModified); + assert.equal(s.fill, 'red'); + assert.deepEqual(s.getModified(), { stroke: 'black' }); + }); + + test('push/pop cycle: applyDiff restores prior state', function () { + const s = new States({ fill: 'red' }); + const beforePush = s.takeDiff(); + s.setValue('fill', 'green'); + assert.equal(s.fill, 'green'); + s.applyDiff(beforePush); + assert.equal(s.fill, 'red'); + assert.deepEqual(s.getModified(), {}); + }); +}); \ No newline at end of file diff --git a/test/unit/core/properties.js b/test/unit/core/properties.js new file mode 100644 index 0000000000..e7def6900e --- /dev/null +++ b/test/unit/core/properties.js @@ -0,0 +1,68 @@ +import p5 from '../../../src/app.js'; + +suite('Set/get properties', function() { + + let p = new p5(function (sketch) { + sketch.setup = function () { }; + sketch.draw = function () { }; + }); + + let getters = { + fill: new p5.Color([100, 200, 50]), + stroke: new p5.Color([200, 100, 50, 100]), + tint: new p5.Color([100, 140, 50]), + + rectMode: p.CENTER, + colorMode: p.HSB, + blendMode: p.BLEND, + imageMode: p.CORNER, + ellipseMode: p.CORNER, + angleMode: p.DEGREES, + + strokeWeight: 6, + strokeCap: p.ROUND, + strokeJoin: p.MITER, + cursor: p.HAND, + pixelDensity: 1, + + bezierOrder: 2, + splineProperties: { ends: p.EXCLUDE, tightness: -5 }, + + textureMode: p.IMAGE, // 3D only + textureWrap: { x: p.REPEAT, y: p.MIRROR }, // 3D only + + textAlign: { horizontal: p.CENTER, vertical: p.CENTER }, + textLeading: 18, + textFont: 'arial', + textSize: 1, + textStyle: 1, + textWrap: p.WORD, + textDirection: 1, + textWeight: 1 + + // see #8278 + // rotate: p.PI, + // translate: { x: 1, y: 2 }, + // scale: { x: 1, y: 2 }, + // background: new p5.Color([100, 100, 50]) + }; + + Object.keys(getters).forEach(prop => { + let arg = getters[prop]; + test(`${prop}()`, function() { + + // setter + if (typeof arg === 'object' && !(arg instanceof p5.Color)) { + p[prop](...Object.values(arg)); // set with object + } + else if (Array.isArray(arg)) { + p[prop](...arg); // set with array + } + else { + p[prop](arg); // set with primitive or p5.Color + } + // getter + assert.strictEqual(p[prop]().toString(), arg.toString(), `${arg.toString()}`); + }); + }); +}); diff --git a/test/unit/io/loadModel.js b/test/unit/io/loadModel.js index f88a5807cc..590a12bb1e 100644 --- a/test/unit/io/loadModel.js +++ b/test/unit/io/loadModel.js @@ -79,11 +79,27 @@ suite('loadModel', function() { assert.deepEqual(model.vertexColors, expectedColors); }); - test('inconsistent vertex coloring throws error', async function() { - // Attempt to load the model and catch the error - await expect(mockP5Prototype.loadModel(inconsistentColorObjFile)) - .rejects - .toThrow('Model coloring is inconsistent. Either all vertices should have colors or none should.'); + test('mixed material coloring loads model with sentinel colors for uncolored vertices', async function() { + const model = await mockP5Prototype.loadModel(inconsistentColorObjFile); + assert.instanceOf(model, Geometry); + assert.equal( + model.vertexColors.length, + model.vertices.length * 4, + 'vertexColors should have four entries per vertex' + ); + const hasSentinel = model.vertexColors.some( + (_, i) => + i % 4 === 0 && + model.vertexColors[i] === -1 && + model.vertexColors[i + 1] === -1 && + model.vertexColors[i + 2] === -1 && + model.vertexColors[i + 3] === -1 + ); + const hasRealColor = model.vertexColors.some( + (_, i) => i % 4 === 0 && model.vertexColors[i] !== -1 + ); + assert.isTrue(hasSentinel, 'Uncolored vertices should have sentinel color'); + assert.isTrue(hasRealColor, 'Colored vertices should retain their color'); }); test('missing MTL file shows OBJ model without vertexColors', async function() { diff --git a/test/unit/math/calculation.js b/test/unit/math/calculation.js index 1e0e62c1d0..82868210b8 100644 --- a/test/unit/math/calculation.js +++ b/test/unit/math/calculation.js @@ -299,6 +299,14 @@ suite('Calculation', function() { result = mockP5Prototype.max([10, 10]); assert.equal(result, 10); }); + test('should handle Infinity as a valid argument', function() { + result = mockP5Prototype.max(Infinity, 42); + assert.equal(result, Infinity); + }); + test('should handle -Infinity as a valid argument', function() { + result = mockP5Prototype.max(-Infinity, 42); + assert.equal(result, 42); + }); }); suite('p5.prototype.min', function() { @@ -331,6 +339,14 @@ suite('Calculation', function() { result = mockP5Prototype.min([10, 10]); assert.equal(result, 10); }); + test('should handle Infinity as a valid argument', function() { + result = mockP5Prototype.min(Infinity, 42); + assert.equal(result, 42); + }); + test('should handle -Infinity as a valid argument', function() { + result = mockP5Prototype.min(-Infinity, 42); + assert.equal(result, -Infinity); + }); }); suite('p5.prototype.norm', function() { diff --git a/test/unit/math/p5.Vector.js b/test/unit/math/p5.Vector.js index 4028ee2dba..df694d71a6 100644 --- a/test/unit/math/p5.Vector.js +++ b/test/unit/math/p5.Vector.js @@ -1,24 +1,48 @@ -import vector from '../../../src/math/p5.Vector.js'; +import { default as vector, Vector } from '../../../src/math/p5.Vector.js'; +import { default as math } from '../../../src/math/math.js'; +import { _defaultEmptyVector, _validatedVectorOperation } from '../../../src/math/patch-vector.js'; import { vi } from 'vitest'; + suite('p5.Vector', function () { var v; + let FESCalled = false; const mockP5 = { - _validateParameters: vi.fn() + _friendlyError: function(msg, func) { + FESCalled = true; + console.warn(msg); + } }; + const options = { p5: mockP5 }; const mockP5Prototype = {}; - beforeEach(async function () { + beforeAll(async function () { + // Makes createVector available + mockP5.Vector = Vector; + math(mockP5, mockP5Prototype); vector(mockP5, mockP5Prototype); + + // Ensures all decorators are used by unit tests + mockP5Prototype.createVector = _defaultEmptyVector( + mockP5Prototype.createVector, + options + ); + + // The following mocks simulate the validation decorator + Vector.prototype.add = _validatedVectorOperation(false)(Vector.prototype.add, options); + Vector.prototype.sub = _validatedVectorOperation(false)(Vector.prototype.sub, options); + Vector.prototype.mult = _validatedVectorOperation(true)(Vector.prototype.mult, options); + Vector.prototype.rem = _validatedVectorOperation(true)(Vector.prototype.rem, options); + Vector.prototype.div = _validatedVectorOperation(true)(Vector.prototype.div, options); }); afterEach(function () {}); suite.todo('p5.prototype.setHeading() RADIANS', function () { beforeEach(function () { - mockP5Prototype.angleMode(mockP5.RADIANS); - v = mockP5Prototype.createVector(1, 1); + mockP5Prototype.angleMode(RADIANS); + v = new Vector(1, 1); v.setHeading(1); }); test('should have heading() value of 1 (RADIANS)', function () { @@ -28,7 +52,7 @@ suite('p5.Vector', function () { suite.todo('p5.prototype.setHeading() DEGREES', function () { beforeEach(function () { - mockP5Prototype.angleMode(mockP5.DEGREES); + mockP5Prototype.angleMode(DEGREES); v = mockP5Prototype.createVector(1, 1); v.setHeading(1); }); @@ -44,7 +68,7 @@ suite('p5.Vector', function () { v = mockP5Prototype.createVector(); }); test('should create instance of p5.Vector', function () { - assert.instanceOf(v, mockP5.Vector); + assert.instanceOf(v, Vector); }); test('should have x, y, z be initialized to 0', function () { @@ -69,6 +93,32 @@ suite('p5.Vector', function () { assert.equal(v.z, 3); }); + test('should have values be initialized to 1,2,3', function () { + assert.deepEqual(v.values, [1, 2, 3]); + }); + + test('should have dimensions initialized to 3', function () { + assert.equal(v.dimensions, 3); + }); + }); + + + suite.todo('p5.prototype.createVector()', function () { + beforeEach(function () { + v = mockP5Prototype.createVector(); + }); + + test('should have x, y, z be initialized to 0,0,0', function () { + console.log(typeof mockP5Prototype.createVector); + assert.equal(v.x, 0); + assert.equal(v.y, 0); + assert.equal(v.z, 0); + }); + + test('should have values be initialized to 0,0,0', function () { + assert.deepEqual(v.values, [0,0,0]); + }); + test('should have dimensions initialized to 3', function () { assert.equal(v.dimensions, 3); }); @@ -76,10 +126,10 @@ suite('p5.Vector', function () { suite('new p5.Vector()', function () { beforeEach(function () { - v = new mockP5.Vector(); + v = new Vector(); }); test('should set constant to DEGREES', function () { - assert.instanceOf(v, mockP5.Vector); + assert.instanceOf(v, Vector); }); test('should have x, y, z be initialized to 0', function () { @@ -91,7 +141,7 @@ suite('p5.Vector', function () { suite('new p5.Vector(1, 2, 3)', function () { beforeEach(function () { - v = new mockP5.Vector(1, 2, 3); + v = new Vector(1, 2, 3); }); test('should have x, y, z be initialized to 1,2,3', function () { @@ -101,9 +151,9 @@ suite('p5.Vector', function () { }); }); - suite('new p5.Vector(1,2,undefined)', function () { + suite('new p5.Vector(1,2)', function () { beforeEach(function () { - v = new mockP5.Vector(1, 2, undefined); + v = new Vector(1, 2); }); test('should have x, y, z be initialized to 1,2,0', function () { @@ -116,13 +166,13 @@ suite('p5.Vector', function () { suite('rotate', function () { suite('p5.Vector.prototype.rotate() [INSTANCE]', function () { test('should return the same object', function () { - v = new mockP5.Vector(0, 1); + v = new Vector(0, 1); expect(v.rotate(Math.PI)).to.eql(v); }); suite.todo('radians', function () { beforeEach(function () { - mockP5Prototype.angleMode(mockP5.RADIANS); + mockP5Prototype.angleMode(RADIANS); }); test('should rotate the vector [0, 1, 0] by pi radians to [0, -1, 0]', function () { @@ -152,7 +202,7 @@ suite('p5.Vector', function () { suite.todo('degrees', function () { beforeEach(function () { - mockP5Prototype.angleMode(mockP5.DEGREES); + mockP5Prototype.angleMode(DEGREES); }); test('should rotate the vector [0, 1, 0] by 180 degrees to [0, -1, 0]', function () { @@ -175,12 +225,12 @@ suite('p5.Vector', function () { suite.todo('p5.Vector.rotate() [CLASS]', function () { beforeEach(function () { - mockP5Prototype.angleMode(mockP5.RADIANS); + mockP5Prototype.angleMode(RADIANS); }); test('should not change the original object', function () { v = mockP5Prototype.createVector(1, 0, 0); - mockP5.Vector.rotate(v, Math.PI / 2); + Vector.rotate(v, Math.PI / 2); expect(v.x).to.equal(1); expect(v.y).to.equal(0); expect(v.z).to.equal(0); @@ -188,7 +238,7 @@ suite('p5.Vector', function () { test('should rotate the vector [0, 1, 0] by pi radians to [0, -1, 0]', function () { v = mockP5Prototype.createVector(0, 1, 0); - const v1 = mockP5.Vector.rotate(v, Math.PI); + const v1 = Vector.rotate(v, Math.PI); expect(v1.x).to.be.closeTo(0, 0.01); expect(v1.y).to.be.closeTo(-1, 0.01); expect(v1.z).to.be.closeTo(0, 0.01); @@ -196,7 +246,7 @@ suite('p5.Vector', function () { test('should rotate the vector [1, 0, 0] by -pi/2 radians to [0, -1, 0]', function () { v = mockP5Prototype.createVector(1, 0, 0); - const v1 = mockP5.Vector.rotate(v, -Math.PI / 2); + const v1 = Vector.rotate(v, -Math.PI / 2); expect(v1.x).to.be.closeTo(0, 0.01); expect(v1.y).to.be.closeTo(-1, 0.01); expect(v1.z).to.be.closeTo(0, 0.01); @@ -207,8 +257,8 @@ suite('p5.Vector', function () { suite('angleBetween', function () { let v1, v2; beforeEach(function () { - v1 = new mockP5.Vector(1, 0, 0); - v2 = new mockP5.Vector(2, 2, 0); + v1 = new Vector(1, 0, 0); + v2 = new Vector(2, 2, 0); }); suite('p5.Vector.prototype.angleBetween() [INSTANCE]', function () { @@ -218,60 +268,60 @@ suite('p5.Vector', function () { }); test('should not trip on rounding issues in 2D space', function () { - v1 = new mockP5.Vector(-11, -20); - v2 = new mockP5.Vector(-5.5, -10); - const v3 = new mockP5.Vector(5.5, 10); + v1 = new Vector(-11, -20); + v2 = new Vector(-5.5, -10); + const v3 = new Vector(5.5, 10); expect(v1.angleBetween(v2)).to.be.closeTo(0, 0.00001); expect(v1.angleBetween(v3)).to.be.closeTo(Math.PI, 0.00001); }); test('should not trip on rounding issues in 3D space', function () { - v1 = new mockP5.Vector(1, 1.1, 1.2); - v2 = new mockP5.Vector(2, 2.2, 2.4); + v1 = new Vector(1, 1.1, 1.2); + v2 = new Vector(2, 2.2, 2.4); expect(v1.angleBetween(v2)).to.be.closeTo(0, 0.00001); }); test('should return NaN for zero vector', function () { - v1 = new mockP5.Vector(0, 0, 0); - v2 = new mockP5.Vector(2, 3, 4); + v1 = new Vector(0, 0, 0); + v2 = new Vector(2, 3, 4); expect(v1.angleBetween(v2)).to.be.NaN; expect(v2.angleBetween(v1)).to.be.NaN; }); test.todo('between [1,0,0] and [1,0,0] should be 0 degrees', function () { - mockP5Prototype.angleMode(mockP5.DEGREES); - v1 = new mockP5.Vector(1, 0, 0); - v2 = new mockP5.Vector(1, 0, 0); + mockP5Prototype.angleMode(DEGREES); + v1 = new Vector(1, 0, 0); + v2 = new Vector(1, 0, 0); expect(v1.angleBetween(v2)).to.equal(0); }); test.todo( 'between [0,3,0] and [0,-3,0] should be 180 degrees', function () { - mockP5Prototype.angleMode(mockP5.DEGREES); - v1 = new mockP5.Vector(0, 3, 0); - v2 = new mockP5.Vector(0, -3, 0); + mockP5Prototype.angleMode(DEGREES); + v1 = new Vector(0, 3, 0); + v2 = new Vector(0, -3, 0); expect(v1.angleBetween(v2)).to.be.closeTo(180, 0.01); } ); test('between [1,0,0] and [2,2,0] should be 1/4 PI radians', function () { - v1 = new mockP5.Vector(1, 0, 0); - v2 = new mockP5.Vector(2, 2, 0); + v1 = new Vector(1, 0, 0); + v2 = new Vector(2, 2, 0); expect(v1.angleBetween(v2)).to.be.closeTo(Math.PI / 4, 0.01); expect(v2.angleBetween(v1)).to.be.closeTo((-1 * Math.PI) / 4, 0.01); }); test('between [2,0,0] and [-2,0,0] should be PI radians', function () { - v1 = new mockP5.Vector(2, 0, 0); - v2 = new mockP5.Vector(-2, 0, 0); + v1 = new Vector(2, 0, 0); + v2 = new Vector(-2, 0, 0); expect(v1.angleBetween(v2)).to.be.closeTo(Math.PI, 0.01); }); test('between [2,0,0] and [-2,-2,0] should be -3/4 PI radians ', function () { - v1 = new mockP5.Vector(2, 0, 0); - v2 = new mockP5.Vector(-2, -2, 0); + v1 = new Vector(2, 0, 0); + v2 = new Vector(-2, -2, 0); expect(v1.angleBetween(v2)).to.be.closeTo( -1 * (Math.PI / 2 + Math.PI / 4), 0.01 @@ -279,8 +329,8 @@ suite('p5.Vector', function () { }); test('between [-2,-2,0] and [2,0,0] should be 3/4 PI radians', function () { - v1 = new mockP5.Vector(-2, -2, 0); - v2 = new mockP5.Vector(2, 0, 0); + v1 = new Vector(-2, -2, 0); + v2 = new Vector(2, 0, 0); expect(v1.angleBetween(v2)).to.be.closeTo( Math.PI / 2 + Math.PI / 4, 0.01 @@ -288,40 +338,40 @@ suite('p5.Vector', function () { }); test('For the same vectors, the angle between them should always be 0.', function () { - v1 = new mockP5.Vector(288, 814); - v2 = new mockP5.Vector(288, 814); + v1 = new Vector(288, 814); + v2 = new Vector(288, 814); expect(v1.angleBetween(v2)).to.equal(0); }); test('The angle between vectors pointing in opposite is always PI.', function () { - v1 = new mockP5.Vector(219, 560); - v2 = new mockP5.Vector(-219, -560); + v1 = new Vector(219, 560); + v2 = new Vector(-219, -560); expect(v1.angleBetween(v2)).to.be.closeTo(Math.PI, 0.0000001); }); }); suite('p5.Vector.angleBetween() [CLASS]', function () { test('should return NaN for zero vector', function () { - v1 = new mockP5.Vector(0, 0, 0); - v2 = new mockP5.Vector(2, 3, 4); - expect(mockP5.Vector.angleBetween(v1, v2)).to.be.NaN; - expect(mockP5.Vector.angleBetween(v2, v1)).to.be.NaN; + v1 = new Vector(0, 0, 0); + v2 = new Vector(2, 3, 4); + expect(Vector.angleBetween(v1, v2)).to.be.NaN; + expect(Vector.angleBetween(v2, v1)).to.be.NaN; }); test.todo( 'between [1,0,0] and [0,-1,0] should be -90 degrees', function () { - mockP5Prototype.angleMode(mockP5.DEGREES); - v1 = new mockP5.Vector(1, 0, 0); - v2 = new mockP5.Vector(0, -1, 0); - expect(mockP5.Vector.angleBetween(v1, v2)).to.be.closeTo(-90, 0.01); + mockP5Prototype.angleMode(DEGREES); + v1 = new Vector(1, 0, 0); + v2 = new Vector(0, -1, 0); + expect(Vector.angleBetween(v1, v2)).to.be.closeTo(-90, 0.01); } ); test('between [0,3,0] and [0,-3,0] should be PI radians', function () { - v1 = new mockP5.Vector(0, 3, 0); - v2 = new mockP5.Vector(0, -3, 0); - expect(mockP5.Vector.angleBetween(v1, v2)).to.be.closeTo(Math.PI, 0.01); + v1 = new Vector(0, 3, 0); + v2 = new Vector(0, -3, 0); + expect(Vector.angleBetween(v1, v2)).to.be.closeTo(Math.PI, 0.01); }); }); }); @@ -329,7 +379,7 @@ suite('p5.Vector', function () { suite('set()', function () { suite('with p5.Vector', function () { test("should have x, y, z be initialized to the vector's x, y, z", function () { - v.set(new mockP5.Vector(2, 5, 6)); + v.set(new Vector(2, 5, 6)); expect(v.x).to.eql(2); expect(v.y).to.eql(5); expect(v.z).to.eql(6); @@ -364,7 +414,7 @@ suite('p5.Vector', function () { suite('copy', function () { beforeEach(function () { - v = new mockP5.Vector(2, 3, 4); + v = new Vector(2, 3, 4); }); suite('p5.Vector.prototype.copy() [INSTANCE]', function () { @@ -383,12 +433,12 @@ suite('p5.Vector', function () { suite('p5.Vector.copy() [CLASS]', function () { test('should not return the same instance', function () { - var newObject = mockP5.Vector.copy(v); + var newObject = Vector.copy(v); expect(newObject).to.not.equal(v); }); test("should return the passed object's x, y, z", function () { - var newObject = mockP5.Vector.copy(v); + var newObject = Vector.copy(v); expect(newObject.x).to.eql(2); expect(newObject.y).to.eql(3); expect(newObject.z).to.eql(4); @@ -398,12 +448,12 @@ suite('p5.Vector', function () { suite('add()', function () { beforeEach(function () { - v = new mockP5.Vector(); + v = new Vector(0, 0, 0); }); suite('with p5.Vector', function () { test('should add x, y, z from the vector argument', function () { - v.add(new mockP5.Vector(1, 5, 6)); + v.add(new Vector(1, 5, 6)); expect(v.x).to.eql(1); expect(v.y).to.eql(5); expect(v.z).to.eql(6); @@ -457,9 +507,9 @@ suite('p5.Vector', function () { suite('p5.Vector.add(v1, v2)', function () { var v1, v2, res; beforeEach(function () { - v1 = new mockP5.Vector(2, 0, 3); - v2 = new mockP5.Vector(0, 1, 3); - res = mockP5.Vector.add(v1, v2); + v1 = new Vector(2, 0, 3); + v2 = new Vector(0, 1, 3); + res = Vector.add(v1, v2); }); test('should return neither v1 nor v2', function () { @@ -477,7 +527,7 @@ suite('p5.Vector', function () { suite('rem()', function () { beforeEach(function () { - v = new mockP5.Vector(3, 4, 5); + v = new Vector(3, 4, 5); }); test('should give same vector if nothing passed as parameter', function () { @@ -495,10 +545,12 @@ suite('p5.Vector', function () { }); test('should give correct output if passed two numeric value', function () { + expect(v.dimensions).to.eql(3); v.rem(2, 3); expect(v.x).to.eql(1); expect(v.y).to.eql(1); - expect(v.z).to.eql(5); + expect(v.z).to.eql(0); + expect(v.dimensions).to.eql(2); }); test('should give correct output if passed three numeric value', function () { @@ -510,28 +562,28 @@ suite('p5.Vector', function () { suite('with p5.Vector', function () { test('should return correct output if only one component is non-zero', function () { - v.rem(new mockP5.Vector(0, 0, 4)); + v.rem(new Vector(0, 0, 4)); expect(v.x).to.eql(3); expect(v.y).to.eql(4); expect(v.z).to.eql(1); }); test('should return correct output if x component is zero', () => { - v.rem(new mockP5.Vector(0, 3, 4)); + v.rem(new Vector(0, 3, 4)); expect(v.x).to.eql(3); expect(v.y).to.eql(1); expect(v.z).to.eql(1); }); test('should return correct output if all components are non-zero', () => { - v.rem(new mockP5.Vector(2, 3, 4)); + v.rem(new Vector(2, 3, 4)); expect(v.x).to.eql(1); expect(v.y).to.eql(1); expect(v.z).to.eql(1); }); test('should return same vector if all components are zero', () => { - v.rem(new mockP5.Vector(0, 0, 0)); + v.rem(new Vector(0, 0, 0)); expect(v.x).to.eql(3); expect(v.y).to.eql(4); expect(v.z).to.eql(5); @@ -541,10 +593,10 @@ suite('p5.Vector', function () { suite('with negative vectors', function () { let v; beforeEach(function () { - v = new mockP5.Vector(-15, -5, -2); + v = new Vector(-15, -5, -2); }); test('should return correct output', () => { - v.rem(new mockP5.Vector(2, 3, 3)); + v.rem(new Vector(2, 3, 3)); expect(v.x).to.eql(-1); expect(v.y).to.eql(-2); expect(v.z).to.eql(-2); @@ -562,14 +614,16 @@ suite('p5.Vector', function () { v.rem([2, 3]); expect(v.x).to.eql(1); expect(v.y).to.eql(1); - expect(v.z).to.eql(5); + expect(v.z).to.eql(0); + expect(v.dimensions).to.eql(2); }); test('should return correct output if x,y components are zero for 2D vector', () => { v.rem([0, 0]); expect(v.x).to.eql(3); expect(v.y).to.eql(4); - expect(v.z).to.eql(5); + expect(v.z).to.eql(0); + expect(v.dimensions).to.eql(2); }); test('should return same vector if any vector component is non-finite number', () => { @@ -583,9 +637,9 @@ suite('p5.Vector', function () { suite('p5.Vector.rem(v1,v2)', function () { let v1, v2, res; beforeEach(function () { - v1 = new mockP5.Vector(2, 3, 4); - v2 = new mockP5.Vector(1, 2, 3); - res = mockP5.Vector.rem(v1, v2); + v1 = new Vector(2, 3, 4); + v2 = new Vector(1, 2, 3); + res = Vector.rem(v1, v2); }); test('should return neither v1 nor v2', function () { @@ -605,14 +659,11 @@ suite('p5.Vector', function () { beforeEach(function () { v.x = 0; v.y = 0; - v.z = 0; }); suite('with p5.Vector', function () { test('should sub x, y, z from the vector argument', function () { - v.sub(new mockP5.Vector(2, 5, 6)); - expect(v.x).to.eql(-2); - expect(v.y).to.eql(-5); - expect(v.z).to.eql(-6); + v.sub(new Vector(2, 5)); + assert.deepEqual(v.values, [-2, -5]); }); }); @@ -626,11 +677,9 @@ suite('p5.Vector', function () { }); }); - test("should subtract from the array's 0,1,2 index", function () { + test("should subtract from the array's 0, 1, 2 index", function () { v.sub([2, 5, 6]); - expect(v.x).to.eql(-2); - expect(v.y).to.eql(-5); - expect(v.z).to.eql(-6); + expect(v.values).to.eql([-2, -5]); }); }); @@ -645,19 +694,19 @@ suite('p5.Vector', function () { suite('sub(2,3,4)', function () { test('should subtract the x, y, z components', function () { - v.sub(5, 5, 5); + v.sub(5, 5); expect(v.x).to.eql(-5); expect(v.y).to.eql(-5); - expect(v.z).to.eql(-5); + //expect(v.z).to.eql(-5); }); }); suite('p5.Vector.sub(v1, v2)', function () { var v1, v2, res; beforeEach(function () { - v1 = new mockP5.Vector(2, 0, 3); - v2 = new mockP5.Vector(0, 1, 3); - res = mockP5.Vector.sub(v1, v2); + v1 = new Vector(2, 0, 3); + v2 = new Vector(0, 1, 3); + res = Vector.sub(v1, v2); }); test('should return neither v1 nor v2', function () { @@ -675,7 +724,7 @@ suite('p5.Vector', function () { suite('mult()', function () { beforeEach(function () { - v = new mockP5.Vector(1, 1, 1); + v = new Vector(1, 1, 1); }); test('should return the same object', function () { @@ -705,11 +754,20 @@ suite('p5.Vector', function () { }); }); + suite('with arglist', function () { + test('multiply the x, y, z with the scalar', function () { + v.mult(2, 3, 4); + expect(v.x).to.eql(2); + expect(v.y).to.eql(3); + expect(v.z).to.eql(4); + }); + }); + suite('v0.mult(v1)', function () { var v0, v1; beforeEach(function () { - v0 = new mockP5.Vector(1, 2, 3); - v1 = new mockP5.Vector(2, 3, 4); + v0 = new Vector(1, 2, 3); + v1 = new Vector(2, 3, 4); v0.mult(v1); }); @@ -723,7 +781,7 @@ suite('p5.Vector', function () { suite('v0.mult(arr)', function () { var v0, arr; beforeEach(function () { - v0 = new mockP5.Vector(1, 2, 3); + v0 = new Vector(1, 2, 3); arr = [2, 3, 4]; v0.mult(arr); }); @@ -738,8 +796,8 @@ suite('p5.Vector', function () { suite('p5.Vector.mult(v, n)', function () { var v, res; beforeEach(function () { - v = new mockP5.Vector(1, 2, 3); - res = mockP5.Vector.mult(v, 4); + v = new Vector(1, 2, 3); + res = Vector.mult(v, 4); }); test('should return a new p5.Vector', function () { @@ -756,9 +814,9 @@ suite('p5.Vector', function () { suite('p5.Vector.mult(v, v', function () { var v0, v1, res; beforeEach(function () { - v0 = new mockP5.Vector(1, 2, 3); - v1 = new mockP5.Vector(2, 3, 4); - res = mockP5.Vector.mult(v0, v1); + v0 = new Vector(1, 2, 3); + v1 = new Vector(2, 3, 4); + res = Vector.mult(v0, v1); }); test('should return new vector from component wise multiplication', function () { @@ -768,12 +826,12 @@ suite('p5.Vector', function () { }); }); - suite('p5.Vector.mult(v, arr', function () { + suite('p5.Vector.mult(v, arr)', function () { var v0, arr, res; beforeEach(function () { - v0 = new mockP5.Vector(1, 2, 3); + v0 = new Vector(1, 2, 3); arr = [2, 3, 4]; - res = mockP5.Vector.mult(v0, arr); + res = Vector.mult(v0, arr); }); test('should return new vector from component wise multiplication with an array', function () { @@ -786,7 +844,7 @@ suite('p5.Vector', function () { suite('div()', function () { beforeEach(function () { - v = new mockP5.Vector(1, 1, 1); + v = new Vector(1, 1, 1); }); test('should return the same object', function () { @@ -823,11 +881,20 @@ suite('p5.Vector', function () { }); }); + suite('with arglist', function () { + test('divide the x, y, z with the scalar', function () { + v.div(2, 3, 4); + expect(v.x).to.be.closeTo(0.5, 0.01); + expect(v.y).to.be.closeTo(0.333, 0.01); + expect(v.z).to.be.closeTo(0.25, 0.01); + }); + }); + suite('p5.Vector.div(v, n)', function () { var v, res; beforeEach(function () { - v = new mockP5.Vector(1, 1, 1); - res = mockP5.Vector.div(v, 4); + v = new Vector(1, 1, 1); + res = Vector.div(v, 4); }); test('should not be undefined', function () { @@ -848,10 +915,10 @@ suite('p5.Vector', function () { suite('v0.div(v1)', function () { var v0, v1, v2, v3; beforeEach(function () { - v0 = new mockP5.Vector(2, 6, 9); - v1 = new mockP5.Vector(2, 2, 3); - v2 = new mockP5.Vector(1, 1, 1); - v3 = new mockP5.Vector(0, 0, 0); + v0 = new Vector(2, 6, 9); + v1 = new Vector(2, 2, 3); + v2 = new Vector(1, 1, 1); + v3 = new Vector(0, 0, 0); v0.div(v1); }); @@ -870,8 +937,8 @@ suite('p5.Vector', function () { }); test('should work on 2D vectors', function () { - const v = new mockP5.Vector(1, 1); - const divisor = new mockP5.Vector(2, 2); + const v = new Vector(1, 1); + const divisor = new Vector(2, 2); v.div(divisor); expect(v.x).to.eql(0.5); expect(v.y).to.eql(0.5); @@ -879,8 +946,8 @@ suite('p5.Vector', function () { }); test('should work when the dividend has 0', function () { - const v = new mockP5.Vector(1, 0); - const divisor = new mockP5.Vector(2, 2); + const v = new Vector(1, 0); + const divisor = new Vector(2, 2); v.div(divisor); expect(v.x).to.eql(0.5); expect(v.y).to.eql(0); @@ -888,8 +955,8 @@ suite('p5.Vector', function () { }); test('should do nothing when the divisor has 0', function () { - const v = new mockP5.Vector(1, 1); - const divisor = new mockP5.Vector(0, 2); + const v = new Vector(1, 1); + const divisor = new Vector(0, 2); v.div(divisor); expect(v.x).to.eql(1); expect(v.y).to.eql(1); @@ -900,8 +967,8 @@ suite('p5.Vector', function () { suite('v0.div(arr)', function () { var v0, v1, arr; beforeEach(function () { - v0 = new mockP5.Vector(2, 6, 9); - v1 = new mockP5.Vector(1, 1, 1); + v0 = new Vector(2, 6, 9); + v1 = new Vector(1, 1, 1); arr = [2, 2, 3]; v0.div(arr); }); @@ -923,9 +990,9 @@ suite('p5.Vector', function () { suite('p5.Vector.div(v, v', function () { var v0, v1, res; beforeEach(function () { - v0 = new mockP5.Vector(2, 6, 9); - v1 = new mockP5.Vector(2, 2, 3); - res = mockP5.Vector.div(v0, v1); + v0 = new Vector(2, 6, 9); + v1 = new Vector(2, 2, 3); + res = Vector.div(v0, v1); }); test('should return new vector from component wise division', function () { @@ -938,9 +1005,9 @@ suite('p5.Vector', function () { suite('p5.Vector.div(v, arr', function () { var v0, arr, res; beforeEach(function () { - v0 = new mockP5.Vector(2, 6, 9); + v0 = new Vector(2, 6, 9); arr = [2, 2, 3]; - res = mockP5.Vector.div(v0, arr); + res = Vector.div(v0, arr); }); test('should return new vector from component wise division with an array', function () { @@ -951,6 +1018,56 @@ suite('p5.Vector', function () { }); }); + + suite('smaller dimension', function () { + let v1, v2, v3; + beforeEach(function () { + v1 = new Vector(1); + v2 = new Vector(2, 3); + v3 = new Vector(4, 5, 6); + }); + + test('should be prioritized in add()', function () { + assert.deepEqual(v1.add(v2).values, [3]); + expect(v1.add(v2).dimensions).to.eql(1); + + assert.deepEqual(v3.add(v2).values, [6, 8]); + expect(v3.add(v2).dimensions).to.eql(2); + }); + + test('should be prioritized in sub()', function () { + assert.deepEqual(v1.sub(v2).values, [-1]); + expect(v1.sub(v2).dimensions).to.eql(1); + + assert.deepEqual(v3.sub(v2).values, [2, 2]); + expect(v3.sub(v2).dimensions).to.eql(2); + }); + + test('should be prioritized in mult()', function () { + assert.deepEqual(v1.mult(v2).values, [2]); + expect(v1.mult(v2).dimensions).to.eql(1); + + assert.deepEqual(v3.mult(v2).values, [8, 15]); + expect(v3.mult(v2).dimensions).to.eql(2); + }); + + test('should be prioritized in div()', function () { + assert.deepEqual(v1.div(v2).values, [1/2]); + expect(v1.div(v2).dimensions).to.eql(1); + + assert.deepEqual(v3.div(v2).values, [2, 5/3]); + expect(v3.div(v2).dimensions).to.eql(2); + }); + + test('should be prioritized in rem()', function () { + assert.deepEqual(v1.rem(v2).values, [1]); + expect(v1.rem(v2).dimensions).to.eql(1); + + assert.deepEqual(v3.rem(v2).values, [0, 2]); + expect(v3.rem(v2).dimensions).to.eql(2); + }); + }); + suite('dot', function () { beforeEach(function () { v.x = 1; @@ -959,12 +1076,12 @@ suite('p5.Vector', function () { }); test('should return a number', function () { - expect(typeof v.dot(new mockP5.Vector()) === 'number').to.eql(true); + expect(typeof v.dot(new Vector()) === 'number').to.eql(true); }); suite('with p5.Vector', function () { test('should be the dot product of the vector', function () { - expect(v.dot(new mockP5.Vector(2, 2))).to.eql(4); + expect(v.dot(new Vector(2, 2))).to.eql(4); }); }); @@ -981,9 +1098,9 @@ suite('p5.Vector', function () { suite('p5.Vector.dot(v, n)', function () { var v1, v2, res; beforeEach(function () { - v1 = new mockP5.Vector(1, 1, 1); - v2 = new mockP5.Vector(2, 3, 4); - res = mockP5.Vector.dot(v1, v2); + v1 = new Vector(1, 1, 1); + v2 = new Vector(2, 3, 4); + res = Vector.dot(v1, v2); }); test('should return a number', function () { @@ -1005,12 +1122,12 @@ suite('p5.Vector', function () { }); test('should return a new product', function () { - expect(v.cross(new mockP5.Vector())).to.not.eql(v); + expect(v.cross(new Vector())).to.not.eql(v); }); suite('with p5.Vector', function () { test('should cross x, y, z from the vector argument', function () { - res = v.cross(new mockP5.Vector(2, 5, 6)); + res = v.cross(new Vector(2, 5, 6)); expect(res.x).to.eql(1); //this.y * v.z - this.z * v.y expect(res.y).to.eql(-4); //this.z * v.x - this.x * v.z expect(res.z).to.eql(3); //this.x * v.y - this.y * v.x @@ -1020,9 +1137,9 @@ suite('p5.Vector', function () { suite('p5.Vector.cross(v1, v2)', function () { var v1, v2, res; beforeEach(function () { - v1 = new mockP5.Vector(3, 6, 9); - v2 = new mockP5.Vector(1, 1, 1); - res = mockP5.Vector.cross(v1, v2); + v1 = new Vector(3, 6, 9); + v2 = new Vector(1, 1, 1); + res = Vector.cross(v1, v2); }); test('should not be undefined', function () { @@ -1045,9 +1162,9 @@ suite('p5.Vector', function () { suite('dist', function () { var b, c; beforeEach(function () { - v = new mockP5.Vector(0, 0, 1); - b = new mockP5.Vector(0, 0, 5); - c = new mockP5.Vector(3, 4, 1); + v = new Vector(0, 0, 1); + b = new Vector(0, 0, 5); + c = new Vector(3, 4, 1); }); test('should return a number', function () { @@ -1070,23 +1187,23 @@ suite('p5.Vector', function () { suite('p5.Vector.dist(v1, v2)', function () { var v1, v2; beforeEach(function () { - v1 = new mockP5.Vector(0, 0, 0); - v2 = new mockP5.Vector(0, 3, 4); + v1 = new Vector(0, 0, 0); + v2 = new Vector(0, 3, 4); }); test('should return a number', function () { - expect(typeof mockP5.Vector.dist(v1, v2) === 'number').to.eql(true); + expect(typeof Vector.dist(v1, v2) === 'number').to.eql(true); }); test('should be commutative', function () { - expect(mockP5.Vector.dist(v1, v2)).to.eql(mockP5.Vector.dist(v2, v1)); + expect(Vector.dist(v1, v2)).to.eql(Vector.dist(v2, v1)); }); }); suite('normalize', function () { suite('p5.Vector.prototype.normalize() [INSTANCE]', function () { beforeEach(function () { - v = new mockP5.Vector(1, 1, 1); + v = new Vector(1, 1, 1); }); test('should return the same object', function () { @@ -1117,8 +1234,8 @@ suite('p5.Vector', function () { suite('p5.Vector.normalize(v) [CLASS]', function () { var res; beforeEach(function () { - v = new mockP5.Vector(1, 0, 0); - res = mockP5.Vector.normalize(v); + v = new Vector(1, 0, 0); + res = Vector.normalize(v); }); test('should not be undefined', function () { @@ -1139,7 +1256,7 @@ suite('p5.Vector', function () { v.x = 2; v.y = 2; v.z = 1; - res = mockP5.Vector.normalize(v); + res = Vector.normalize(v); expect(res.x).to.be.closeTo(0.6666, 0.01); expect(res.y).to.be.closeTo(0.6666, 0.01); expect(res.z).to.be.closeTo(0.3333, 0.01); @@ -1151,7 +1268,7 @@ suite('p5.Vector', function () { let v; beforeEach(function () { - v = new mockP5.Vector(5, 5, 5); + v = new Vector(5, 5, 5); }); suite('p5.Vector.prototype.limit() [INSTANCE]', function () { @@ -1180,12 +1297,12 @@ suite('p5.Vector', function () { suite('p5.Vector.limit() [CLASS]', function () { test('should not return the same object', function () { - expect(mockP5.Vector.limit(v)).to.not.equal(v); + expect(Vector.limit(v)).to.not.equal(v); }); suite('with a vector larger than the limit', function () { test('should limit the vector', function () { - const res = mockP5.Vector.limit(v, 1); + const res = Vector.limit(v, 1); expect(res.x).to.be.closeTo(0.5773, 0.01); expect(res.y).to.be.closeTo(0.5773, 0.01); expect(res.z).to.be.closeTo(0.5773, 0.01); @@ -1194,7 +1311,7 @@ suite('p5.Vector', function () { suite('with a vector smaller than the limit', function () { test('should not limit the vector', function () { - const res = mockP5.Vector.limit(v, 8.67); + const res = Vector.limit(v, 8.67); expect(res.x).to.eql(5); expect(res.y).to.eql(5); expect(res.z).to.eql(5); @@ -1203,8 +1320,8 @@ suite('p5.Vector', function () { suite('when given a target vector', function () { test('should store limited vector in the target', function () { - const target = new mockP5.Vector(0, 0, 0); - mockP5.Vector.limit(v, 1, target); + const target = new Vector(0, 0, 0); + Vector.limit(v, 1, target); expect(target.x).to.be.closeTo(0.5773, 0.01); expect(target.y).to.be.closeTo(0.5773, 0.01); expect(target.z).to.be.closeTo(0.5773, 0.01); @@ -1217,7 +1334,7 @@ suite('p5.Vector', function () { let v; beforeEach(function () { - v = new mockP5.Vector(1, 0, 0); + v = new Vector(1, 0, 0); }); suite('p5.Vector.setMag() [INSTANCE]', function () { @@ -1245,11 +1362,11 @@ suite('p5.Vector', function () { suite('p5.Vector.prototype.setMag() [CLASS]', function () { test('should not return the same object', function () { - expect(mockP5.Vector.setMag(v, 2)).to.not.equal(v); + expect(Vector.setMag(v, 2)).to.not.equal(v); }); test('should set the magnitude of the vector', function () { - const res = mockP5.Vector.setMag(v, 4); + const res = Vector.setMag(v, 4); expect(res.x).to.eql(4); expect(res.y).to.eql(0); expect(res.z).to.eql(0); @@ -1259,7 +1376,7 @@ suite('p5.Vector', function () { v.x = 2; v.y = 3; v.z = -6; - const res = mockP5.Vector.setMag(v, 14); + const res = Vector.setMag(v, 14); expect(res.x).to.eql(4); expect(res.y).to.eql(6); expect(res.z).to.eql(-12); @@ -1267,8 +1384,8 @@ suite('p5.Vector', function () { suite('when given a target vector', function () { test('should set the magnitude on the target', function () { - const target = new mockP5.Vector(0, 1, 0); - const res = mockP5.Vector.setMag(v, 4, target); + const target = new Vector(0, 1, 0); + const res = Vector.setMag(v, 4, target); expect(target).to.equal(res); expect(target.x).to.eql(4); expect(target.y).to.eql(0); @@ -1280,7 +1397,7 @@ suite('p5.Vector', function () { suite('heading', function () { beforeEach(function () { - v = new mockP5.Vector(); + v = new Vector(0,0,0); }); suite('p5.Vector.prototype.heading() [INSTANCE]', function () { @@ -1311,7 +1428,7 @@ suite('p5.Vector', function () { suite.todo('with `angleMode(DEGREES)`', function () { beforeEach(function () { - mockP5Prototype.angleMode(mockP5.DEGREES); + mockP5Prototype.angleMode(DEGREES); }); test('heading for vector pointing right is 0', function () { @@ -1339,28 +1456,28 @@ suite('p5.Vector', function () { suite('p5.Vector.heading() [CLASS]', function () { test('should return a number', function () { - expect(typeof mockP5.Vector.heading(v) === 'number').to.eql(true); + expect(typeof Vector.heading(v) === 'number').to.eql(true); }); test('heading for vector pointing right is 0', function () { v.x = 1; v.y = 0; v.z = 0; - expect(mockP5.Vector.heading(v)).to.be.closeTo(0, 0.01); + expect(Vector.heading(v)).to.be.closeTo(0, 0.01); }); test('heading for vector pointing down is PI/2', function () { v.x = 0; v.y = 1; v.z = 0; - expect(mockP5.Vector.heading(v)).to.be.closeTo(Math.PI / 2, 0.01); + expect(Vector.heading(v)).to.be.closeTo(Math.PI / 2, 0.01); }); test('heading for vector pointing left is PI', function () { v.x = -1; v.y = 0; v.z = 0; - expect(mockP5.Vector.heading(v)).to.be.closeTo(Math.PI, 0.01); + expect(Vector.heading(v)).to.be.closeTo(Math.PI, 0.01); }); }); }); @@ -1370,14 +1487,13 @@ suite('p5.Vector', function () { expect(v.lerp()).to.eql(v); }); - // PEND: ADD BACK IN - // suite('with p5.Vector', function() { - // test('should call lerp with 4 arguments', function() { - // spyOn(v, 'lerp').andCallThrough(); - // v.lerp(new p5.Vector(1,2,3), 1); - // expect(v.lerp).toHaveBeenCalledWith(1, 2, 3, 1); - // }); - // }); + suite('with p5.Vector', function() { + test('should call lerp with 4 arguments', function() { + vi.spyOn(v, 'lerp'); + v.lerp(new Vector(1,2,3), 1); + expect(v.lerp).toHaveBeenCalledWith(1, 2, 3, 1); + }); + }); suite('with x, y, z, amt', function () { beforeEach(function () { @@ -1416,9 +1532,9 @@ suite('p5.Vector', function () { suite('p5.Vector.lerp(v1, v2, amt)', function () { var res, v1, v2; beforeEach(function () { - v1 = new mockP5.Vector(0, 0, 0); - v2 = new mockP5.Vector(2, 2, 2); - res = mockP5.Vector.lerp(v1, v2, 0.5); + v1 = new Vector(0, 0, 0); + v2 = new Vector(2, 2, 2); + res = Vector.lerp(v1, v2, 0.5); }); test('should not be undefined', function () { @@ -1426,7 +1542,7 @@ suite('p5.Vector', function () { }); test('should be a p5.Vector', function () { - expect(res).to.be.an.instanceof(mockP5.Vector); + expect(res).to.be.an.instanceof(Vector); }); test('should return neither v1 nor v2', function () { @@ -1445,7 +1561,7 @@ suite('p5.Vector', function () { var w; beforeEach(function () { v.set(1, 2, 3); - w = new mockP5.Vector(4, 6, 8); + w = new Vector(4, 6, 8); }); test('if amt is 0, returns original vector', function () { @@ -1496,9 +1612,9 @@ suite('p5.Vector', function () { suite('p5.Vector.slerp(v1, v2, amt)', function () { var res, v1, v2; beforeEach(function () { - v1 = new mockP5.Vector(1, 0, 0); - v2 = new mockP5.Vector(0, 0, 1); - res = mockP5.Vector.slerp(v1, v2, 1 / 3); + v1 = new Vector(1, 0, 0); + v2 = new Vector(0, 0, 1); + res = Vector.slerp(v1, v2, 1 / 3); }); test('should not be undefined', function () { @@ -1506,7 +1622,7 @@ suite('p5.Vector', function () { }); test('should be a p5.Vector', function () { - expect(res).to.be.an.instanceof(mockP5.Vector); + expect(res).to.be.an.instanceof(Vector); }); test('should return neither v1 nor v2', function () { @@ -1521,14 +1637,14 @@ suite('p5.Vector', function () { }); test('Make sure the interpolation in -1/3 is correct', function () { - mockP5.Vector.slerp(v1, v2, -1 / 3, res); + Vector.slerp(v1, v2, -1 / 3, res); expect(res.x).to.be.closeTo(Math.cos(-Math.PI / 6), 0.00001); expect(res.y).to.be.closeTo(0, 0.00001); expect(res.z).to.be.closeTo(Math.sin(-Math.PI / 6), 0.00001); }); test('Make sure the interpolation in 5/3 is correct', function () { - mockP5.Vector.slerp(v1, v2, 5 / 3, res); + Vector.slerp(v1, v2, 5 / 3, res); expect(res.x).to.be.closeTo(Math.cos((5 * Math.PI) / 6), 0.00001); expect(res.y).to.be.closeTo(0, 0.00001); expect(res.z).to.be.closeTo(Math.sin((5 * Math.PI) / 6), 0.00001); @@ -1539,7 +1655,7 @@ suite('p5.Vector', function () { var res, angle; beforeEach(function () { angle = Math.PI / 2; - res = mockP5.Vector.fromAngle(angle); + res = Vector.fromAngle(angle); }); test('should be a p5.Vector with values (0,1)', function () { @@ -1551,7 +1667,7 @@ suite('p5.Vector', function () { suite('p5.Vector.random2D()', function () { var res; beforeEach(function () { - res = mockP5.Vector.random2D(); + res = Vector.random2D(); }); test('should be a unit p5.Vector', function () { @@ -1562,7 +1678,7 @@ suite('p5.Vector', function () { suite('p5.Vector.random3D()', function () { var res; beforeEach(function () { - res = mockP5.Vector.random3D(); + res = Vector.random3D(); }); test('should be a unit p5.Vector', function () { expect(res.mag()).to.be.closeTo(1, 0.01); @@ -1571,7 +1687,7 @@ suite('p5.Vector', function () { suite('array', function () { beforeEach(function () { - v = new mockP5.Vector(1, 23, 4); + v = new Vector(1, 23, 4); }); suite('p5.Vector.prototype.array() [INSTANCE]', function () { @@ -1586,11 +1702,11 @@ suite('p5.Vector', function () { suite('p5.Vector.array() [CLASS]', function () { test('should return an array', function () { - expect(mockP5.Vector.array(v)).to.be.instanceof(Array); + expect(Vector.array(v)).to.be.instanceof(Array); }); test('should return an with the x y and z components', function () { - expect(mockP5.Vector.array(v)).to.eql([1, 23, 4]); + expect(Vector.array(v)).to.eql([1, 23, 4]); }); }); }); @@ -1609,31 +1725,31 @@ suite('p5.Vector', function () { incoming_x = 1; incoming_y = 1; incoming_z = 1; - original_incoming = new mockP5.Vector( + original_incoming = new Vector( incoming_x, incoming_y, incoming_z ); - x_normal = new mockP5.Vector(3, 0, 0); - y_normal = new mockP5.Vector(0, 3, 0); - z_normal = new mockP5.Vector(0, 0, 3); + x_normal = new Vector(3, 0, 0); + y_normal = new Vector(0, 3, 0); + z_normal = new Vector(0, 0, 3); - x_bounce_incoming = new mockP5.Vector( + x_bounce_incoming = new Vector( incoming_x, incoming_y, incoming_z ); x_bounce_outgoing = x_bounce_incoming.reflect(x_normal); - y_bounce_incoming = new mockP5.Vector( + y_bounce_incoming = new Vector( incoming_x, incoming_y, incoming_z ); y_bounce_outgoing = y_bounce_incoming.reflect(y_normal); - z_bounce_incoming = new mockP5.Vector( + z_bounce_incoming = new Vector( incoming_x, incoming_y, incoming_z @@ -1642,9 +1758,9 @@ suite('p5.Vector', function () { }); test('should return a p5.Vector', function () { - expect(x_bounce_incoming).to.be.an.instanceof(mockP5.Vector); - expect(y_bounce_incoming).to.be.an.instanceof(mockP5.Vector); - expect(z_bounce_incoming).to.be.an.instanceof(mockP5.Vector); + expect(x_bounce_incoming).to.be.an.instanceof(Vector); + expect(y_bounce_incoming).to.be.an.instanceof(Vector); + expect(z_bounce_incoming).to.be.an.instanceof(Vector); }); test('should update this', function () { @@ -1720,47 +1836,47 @@ suite('p5.Vector', function () { incoming_x = 1; incoming_y = 1; incoming_z = 1; - original_incoming = new mockP5.Vector( + original_incoming = new Vector( incoming_x, incoming_y, incoming_z ); - x_target = new mockP5.Vector(); - y_target = new mockP5.Vector(); - z_target = new mockP5.Vector(); + x_target = new Vector(0, 0, 0); + y_target = new Vector(0, 0, 0); + z_target = new Vector(0, 0, 0); - x_normal = new mockP5.Vector(3, 0, 0); - y_normal = new mockP5.Vector(0, 3, 0); - z_normal = new mockP5.Vector(0, 0, 3); + x_normal = new Vector(3, 0, 0); + y_normal = new Vector(0, 3, 0); + z_normal = new Vector(0, 0, 3); - x_bounce_incoming = new mockP5.Vector( + x_bounce_incoming = new Vector( incoming_x, incoming_y, incoming_z ); - x_bounce_outgoing = mockP5.Vector.reflect( + x_bounce_outgoing = Vector.reflect( x_bounce_incoming, x_normal, x_target ); - y_bounce_incoming = new mockP5.Vector( + y_bounce_incoming = new Vector( incoming_x, incoming_y, incoming_z ); - y_bounce_outgoing = mockP5.Vector.reflect( + y_bounce_outgoing = Vector.reflect( y_bounce_incoming, y_normal, y_target ); - z_bounce_incoming = new mockP5.Vector( + z_bounce_incoming = new Vector( incoming_x, incoming_y, incoming_z ); - z_bounce_outgoing = mockP5.Vector.reflect( + z_bounce_outgoing = Vector.reflect( z_bounce_incoming, z_normal, z_target @@ -1768,9 +1884,9 @@ suite('p5.Vector', function () { }); test('should return a p5.Vector', function () { - expect(x_bounce_incoming).to.be.an.instanceof(mockP5.Vector); - expect(y_bounce_incoming).to.be.an.instanceof(mockP5.Vector); - expect(z_bounce_incoming).to.be.an.instanceof(mockP5.Vector); + expect(x_bounce_incoming).to.be.an.instanceof(Vector); + expect(y_bounce_incoming).to.be.an.instanceof(Vector); + expect(z_bounce_incoming).to.be.an.instanceof(Vector); }); test('should not update this', function () { @@ -1846,8 +1962,8 @@ suite('p5.Vector', function () { let v1; beforeEach(function () { - v0 = new mockP5.Vector(0, 0, 0); - v1 = new mockP5.Vector(1, 2, 3); + v0 = new Vector(0, 0, 0); + v1 = new Vector(1, 2, 3); }); suite('p5.Vector.prototype.mag() [INSTANCE]', function () { @@ -1859,8 +1975,8 @@ suite('p5.Vector', function () { suite('p5.Vector.mag() [CLASS]', function () { test('should return the magnitude of the vector', function () { - expect(mockP5.Vector.mag(v0)).to.eql(0); - expect(mockP5.Vector.mag(v1)).to.eql(MAG); + expect(Vector.mag(v0)).to.eql(0); + expect(Vector.mag(v1)).to.eql(MAG); }); }); }); @@ -1872,8 +1988,8 @@ suite('p5.Vector', function () { let v1; beforeEach(function () { - v0 = new mockP5.Vector(0, 0, 0); - v1 = new mockP5.Vector(1, 2, 3); + v0 = new Vector(0, 0, 0); + v1 = new Vector(1, 2, 3); }); suite('p5.Vector.prototype.magSq() [INSTANCE]', function () { @@ -1885,8 +2001,8 @@ suite('p5.Vector', function () { suite('p5.Vector.magSq() [CLASS]', function () { test('should return the magnitude of the vector', function () { - expect(mockP5.Vector.magSq(v0)).to.eql(0); - expect(mockP5.Vector.magSq(v1)).to.eql(MAG); + expect(Vector.magSq(v0)).to.eql(0); + expect(Vector.magSq(v1)).to.eql(MAG); }); }); }); @@ -1894,8 +2010,8 @@ suite('p5.Vector', function () { suite('equals', function () { suite('p5.Vector.prototype.equals() [INSTANCE]', function () { test('should return false for parameters inequal to the vector', function () { - const v1 = new mockP5.Vector(0, -1, 1); - const v2 = new mockP5.Vector(1, 2, 3); + const v1 = new Vector(0, -1, 1); + const v2 = new Vector(1, 2, 3); const a2 = [1, 2, 3]; expect(v1.equals(v2)).to.be.false; expect(v1.equals(a2)).to.be.false; @@ -1903,88 +2019,89 @@ suite('p5.Vector', function () { }); test('should return true for equal vectors', function () { - const v1 = new mockP5.Vector(0, -1, 1); - const v2 = new mockP5.Vector(0, -1, 1); + const v1 = new Vector(0, -1, 1); + const v2 = new Vector(0, -1, 1); expect(v1.equals(v2)).to.be.true; }); test('should return true for arrays equal to the vector', function () { - const v1 = new mockP5.Vector(0, -1, 1); + const v1 = new Vector(0, -1, 1); const a1 = [0, -1, 1]; expect(v1.equals(a1)).to.be.true; }); test('should return true for arguments equal to the vector', function () { - const v1 = new mockP5.Vector(0, -1, 1); + const v1 = new Vector(0, -1, 1); expect(v1.equals(0, -1, 1)).to.be.true; }); }); suite('p5.Vector.equals() [CLASS]', function () { test('should return false for inequal parameters', function () { - const v1 = new mockP5.Vector(0, -1, 1); - const v2 = new mockP5.Vector(1, 2, 3); + const v1 = new Vector(0, -1, 1); + const v2 = new Vector(1, 2, 3); const a2 = [1, 2, 3]; - expect(mockP5.Vector.equals(v1, v2)).to.be.false; - expect(mockP5.Vector.equals(v1, a2)).to.be.false; - expect(mockP5.Vector.equals(a2, v1)).to.be.false; + expect(Vector.equals(v1, v2)).to.be.false; + expect(Vector.equals(v1, a2)).to.be.false; + expect(Vector.equals(a2, v1)).to.be.false; }); test('should return true for equal vectors', function () { - const v1 = new mockP5.Vector(0, -1, 1); - const v2 = new mockP5.Vector(0, -1, 1); - expect(mockP5.Vector.equals(v1, v2)).to.be.true; + const v1 = new Vector(0, -1, 1); + const v2 = new Vector(0, -1, 1); + expect(Vector.equals(v1, v2)).to.be.true; }); test('should return true for equal vectors and arrays', function () { - const v1 = new mockP5.Vector(0, -1, 1); + const v1 = new Vector(0, -1, 1); const a1 = [0, -1, 1]; - expect(mockP5.Vector.equals(v1, a1)).to.be.true; - expect(mockP5.Vector.equals(a1, v1)).to.be.true; + expect(Vector.equals(v1, a1)).to.be.true; + expect(Vector.equals(a1, v1)).to.be.true; }); test('should return true for equal arrays', function () { const a1 = [0, -1, 1]; const a2 = [0, -1, 1]; - expect(mockP5.Vector.equals(a1, a2)).to.be.true; + expect(Vector.equals(a1, a2)).to.be.true; }); }); }); suite('set values', function () { beforeEach(function () { - v = new mockP5.Vector(); + v = new Vector(); }); - test('should set values to [0,0,0] if values array is empty', function () { + test('should NOT set values to [0,0,0] if values array is empty', function () { v.values = []; assert.equal(v.x, 0); assert.equal(v.y, 0); assert.equal(v.z, 0); - assert.equal(v.dimensions, 2); + assert.equal(v.dimensions, 0); }); }); suite('get value', function () { test('should return element in range of a non empty vector', function () { - let vect = new mockP5.Vector(1, 2, 3, 4); + let vect = new Vector(1, 2, 3, 4); assert.equal(vect.getValue(0), 1); assert.equal(vect.getValue(1), 2); assert.equal(vect.getValue(2), 3); assert.equal(vect.getValue(3), 4); }); - test.fails( - 'should throw friendly error if attempting to get element outside lenght', + test('should throw friendly error if attempting to get element outside length', function () { - let vect = new mockP5.Vector(1, 2, 3, 4); - assert.equal(vect.getValue(5), 1); + let vect = new Vector(1, 2, 3, 4); + FESCalled = false; + assert.equal(vect.getValue(5), undefined); + assert.equal(FESCalled, true); } ); }); suite('set value', function () { test('should set value of element in range', function () { - let vect = new mockP5.Vector(1, 2, 3, 4); + let vect = new Vector(1, 2, 3, 4); vect.setValue(0, 7); assert.equal(vect.getValue(0), 7); assert.equal(vect.getValue(1), 2); @@ -1992,30 +2109,31 @@ suite('p5.Vector', function () { assert.equal(vect.getValue(3), 4); }); - test.fails( - 'should throw friendly error if attempting to set element outside lenght', + test('should throw friendly error if attempting to set element outside lenght', function () { - let vect = new mockP5.Vector(1, 2, 3, 4); + let vect = new Vector(1, 2, 3, 4); + FESCalled = false; vect.setValue(100, 7); + assert.equal(FESCalled, true); } ); }); describe('get w', () => { it('should return the w component of the vector', () => { - v = new mockP5.Vector(1, 2, 3, 4); + v = new Vector(1, 2, 3, 4); expect(v.w).toBe(4); }); it('should return 0 if w component is not set', () => { - v = new mockP5.Vector(1, 2, 3); + v = new Vector(1, 2, 3); expect(v.w).toBe(0); }); }); describe('set w', () => { it('should set 4th dimension of vector to w value if it exists', () => { - v = new mockP5.Vector(1, 2, 3, 4); + v = new Vector(1, 2, 3, 4); v.w = 7; expect(v.x).toBe(1); expect(v.y).toBe(2); @@ -2024,24 +2142,22 @@ suite('p5.Vector', function () { }); it('should throw error if trying to set w if vector dimensions is less than 4', () => { - v = new mockP5.Vector(1, 2); + v = new Vector(1, 2); v.w = 5; - console.log(v); - console.log(v.w); expect(v.w).toBe(0); //TODO: Check this, maybe this should fail }); }); describe('vector to string', () => { it('should return the string version of a vector', () => { - v = new mockP5.Vector(1, 2, 3, 4); + v = new Vector(1, 2, 3, 4); expect(v.toString()).toBe('vector[1, 2, 3, 4]'); }); }); describe('set heading', () => { it('should rotate a 2D vector by specified angle without changing magnitude', () => { - v = new mockP5.Vector(0, 2); + v = new Vector(0, 2); const mag = v.mag(); expect(v.setHeading(2 * Math.PI).mag()).toBe(mag); expect(v.x).toBe(2); @@ -2051,23 +2167,23 @@ suite('p5.Vector', function () { describe('clamp to zero', () => { it('should clamp values cloze to zero to zero, with Number.epsilon value', () => { - v = new mockP5.Vector(0, 1, 0.5, 0.1, 0.0000000000000001); + v = new Vector(0, 1, 0.5, 0.1, 0.0000000000000001); expect(v.clampToZero().values).toEqual([0, 1, 0.5, 0.1, 0]); }); }); suite('p5.Vector.fromAngles()', function () { - it('should create a v3ctor froma pair of ISO spherical angles', () => { - let vect = mockP5.Vector.fromAngles(0, 0); + it('should create a vector froma pair of ISO spherical angles', () => { + let vect = Vector.fromAngles(0, 0); expect(vect.values).toEqual([0, -1, 0]); }); }); suite('p5.Vector.rotate()', function () { it('should rotate the vector (only 2D vectors) by the given angle; magnitude remains the same.', () => { - v = new mockP5.Vector(0, 1, 2); - let target = new mockP5.Vector(); - mockP5.Vector.rotate(v, 1 * Math.PI, target); + v = new Vector(0, 1, 2); + let target = new Vector(); + Vector.rotate(v, 1 * Math.PI, target); expect(target.values).toEqual([ -4.10759023698152e-16, -2.23606797749979, 2 ]); diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js index 1893e40252..7519d4e2ea 100644 --- a/test/unit/visual/cases/webgl.js +++ b/test/unit/visual/cases/webgl.js @@ -272,6 +272,37 @@ visualSuite('WebGL', function() { screenshot(); }); + + for (const mode of ['webgl', '2d']) { + visualTest(`Transparent background colors are correct in ${mode} mode`, function(p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + const g = p5.createGraphics(50, 50, mode === 'webgl' ? p5.WEBGL : p5.P2D); + if (mode === 'webgl') g.translate(-p5.width/2, -p5.height/2); + g.noStroke(); + g.fill(255, 0, 0, 100); + g.rect(10, 10, 30, 30); + g.filter(p5.BLUR, 4); + p5.imageMode(p5.CENTER); + p5.image(g, 0, 0); + screenshot(); + }); + + visualTest(`Multiple filter passes work correctly on a p5.Graphics in ${mode} mode`, function(p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + const g = p5.createGraphics(50, 50, mode === 'webgl' ? p5.WEBGL : p5.P2D); + if (mode === 'webgl') g.translate(-g.width/2, -g.height/2); + g.background(255); + g.noStroke(); + g.fill(0); + g.rect(10, 10, 6, 6); + g.filter(p5.BLUR, 2); + g.rect(30, 30, 6, 6); + g.filter(p5.BLUR, 2); + p5.imageMode(p5.CENTER); + p5.image(g, 0, 0); + screenshot(); + }); + } }); visualSuite('Lights', function() { @@ -614,6 +645,15 @@ visualSuite('WebGL', function() { p5.circle(0, 0, 50); screenshot(); }); + + visualTest('noTint() before image() does not throw', async (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const img = await p5.loadImage('/test/unit/assets/cat.jpg'); + p5.noTint(); + p5.imageMode(p5.CENTER); + p5.image(img, 0, 0, 50, 50); + screenshot(); + }); }); visualSuite('Hooks coordinate spaces', () => { @@ -1034,6 +1074,33 @@ visualSuite('WebGL', function() { p5.model(obj, 25); screenshot(); }); + visualTest('instanceID in fragment hook colors instances', (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const numInstances = 4; + const shader = p5.baseMaterialShader().modify(() => { + // Vertex hook: position instances in a horizontal row + p5.getWorldInputs((inputs) => { + const id = p5.instanceID(); + const spacing = 12; + const offset = (id - (numInstances - 1) / 2.0) * spacing; + inputs.position.x += offset; + return inputs; + }); + // Fragment hook: color each instance based on instanceID + p5.getFinalColor((color) => { + const id = p5.instanceID(); + const t = id / (numInstances - 1.0); + color = [t, t, t, 1]; + return color; + }); + }, { p5, numInstances }); + p5.background(128); + p5.noStroke(); + p5.shader(shader); + const obj = p5.buildGeometry(() => p5.circle(0, 0, 10)); + p5.model(obj, numInstances); + screenshot(); + }); }); visualSuite('p5.strands', () => { @@ -1049,6 +1116,23 @@ visualSuite('WebGL', function() { screenshot(); }); + visualTest('random() colors a basic shader', (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.baseColorShader().modify(() => { + p5.randomSeed(12); + p5.getFinalColor((color) => { + const value = p5.random(0.2, 0.9); + color = [value, value, value, 1]; + return color; + }); + }, { p5 }); + p5.background(0); + p5.noStroke(); + p5.shader(shader); + p5.plane(50, 50); + screenshot(); + }); + visualTest('uses width/height in getFinalColor', (p5, screenshot) => { let firstShader; function firstShaderCallback() { @@ -1067,6 +1151,60 @@ visualSuite('WebGL', function() { screenshot(); }); + visualTest('lerp maps to mix in strands context', (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + // lerp should behave identically to mix inside strands + const shader = p5.baseColorShader().modify(() => { + p5.getFinalColor((color) => { + color = p5.lerp( + [1, 0, 0, 1], + [0, 0, 1, 1], + 0.5 + ); + return color; + }); + }, { p5 }); + p5.background(0); + p5.shader(shader); + p5.noStroke(); + p5.plane(50, 50); + screenshot(); + }); + + visualTest('mix produces same result as lerp in strands', (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + // mix directly, should produce identical output to lerp test above + const shader = p5.baseColorShader().modify(() => { + p5.getFinalColor((color) => { + color = p5.mix( + [1, 0, 0, 1], + [0, 0, 1, 1], + 0.5 + ); + return color; + }); + }, { p5 }); + p5.background(0); + p5.shader(shader); + p5.noStroke(); + p5.plane(50, 50); + screenshot(); + }); + + visualTest('texCoord is available in getFinalColor', (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.baseColorShader().modify(() => { + p5.finalColor.begin(); + p5.finalColor.set([p5.finalColor.texCoord, 0, 1]); + p5.finalColor.end(); + }, { p5 }); + p5.background(0); + p5.shader(shader); + p5.noStroke(); + p5.plane(50, 50); + screenshot(); + }); + visualSuite('auto-return for shader hooks', () => { visualTest('auto-returns input struct when return is omitted', (p5, screenshot) => { p5.createCanvas(50, 50, p5.WEBGL); @@ -1293,6 +1431,39 @@ visualSuite('WebGL', function() { screenshot(); }); + + visualTest('setUniform with p5.Vector offsets position', (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const myShader = p5.baseMaterialShader().modify(() => { + const uOffset = p5.uniformVec2('uOffset'); + p5.worldInputs.begin(); + p5.worldInputs.position.xy += uOffset; + p5.worldInputs.end(); + }, { p5 }); + p5.background(200); + p5.shader(myShader); + myShader.setUniform('uOffset', p5.createVector(10, -10)); + p5.noStroke(); + p5.fill('red'); + p5.circle(0, 0, 20); + screenshot(); + }); + + visualTest('setUniform with p5.Color sets final color', (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const myShader = p5.baseMaterialShader().modify(() => { + const uColor = p5.uniformVec4('uColor'); + p5.finalColor.begin(); + p5.finalColor.set(uColor); + p5.finalColor.end(); + }, { p5 }); + p5.background(200); + p5.shader(myShader); + myShader.setUniform('uColor', p5.color(0, 100, 200)); + p5.noStroke(); + p5.circle(0, 0, 30); + screenshot(); + }); }); visualSuite('background()', function () { @@ -1392,6 +1563,17 @@ visualSuite('WebGL', function() { }); }); + visualSuite('2D Shapes', function() { + visualTest('rect() rounded into a circle', function(p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + p5.background(255); + p5.noStroke(); + p5.fill('red'); + p5.rect(-20, -20, 40, 40, 20); + screenshot(); + }); + }); + visualSuite('3D Primitives', function() { visualTest('cylinder() renders correctly', function(p5, screenshot) { p5.createCanvas(100, 100, p5.WEBGL); diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index 9dbc344dea..b7c613603d 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -272,8 +272,101 @@ visualSuite("WebGPU", function () { p5.filter(invert); await screenshot(); }); + + visualTest('filter shaders can sample a texture inside a conditional branch', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + p5.background(255); + p5.noStroke(); + p5.fill(0); + p5.circle(0, 0, 20); + // This shader only samples the texture for pixels in the left half of the + // canvas, exercising getTexture() inside a non-uniform conditional + const conditionalInvert = p5.buildFilterShader(({ p5 }) => { + p5.filterColor.begin(); + if (p5.filterColor.texCoord.x < 0.5) { + const col = p5.getTexture( + p5.filterColor.canvasContent, + p5.filterColor.texCoord + ); + p5.filterColor.set([1 - col.rgb, col.a]); + } else { + p5.filterColor.set([0, 0, 1, 1]); + } + p5.filterColor.end(); + }, { p5 }); + p5.filter(conditionalInvert); + await screenshot(); + }); + + visualTest('instanceID in fragment hook colors instances (WebGPU)', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const numInstances = 4; + const shader = p5.baseMaterialShader().modify(() => { + // Vertex hook: position instances in a horizontal row + p5.getWorldInputs((inputs) => { + const id = p5.instanceID(); + const spacing = 12; + const offset = (id - (numInstances - 1) / 2.0) * spacing; + inputs.position.x += offset; + return inputs; + }); + // Fragment hook: color each instance based on instanceID + p5.getFinalColor((color) => { + const id = p5.instanceID(); + const t = id / (numInstances - 1.0); + color = [t, t, t, 1]; + return color; + }); + }, { p5, numInstances }); + p5.background(128); + p5.noStroke(); + p5.shader(shader); + const obj = p5.buildGeometry(() => p5.circle(0, 0, 10)); + p5.model(obj, numInstances); + await screenshot(); + }); + + visualTest('random() colors a basic shader (WebGPU)', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const shader = p5.baseColorShader().modify(() => { + p5.randomSeed(12); + p5.getFinalColor((color) => { + const value = p5.random(0.2, 0.9); + color = [value, value, value, 1]; + return color; + }); + }, { p5 }); + p5.background(0); + p5.noStroke(); + p5.shader(shader); + p5.plane(50, 50); + await screenshot(); + }); + + visualTest('random() in a fragment loop averages to gray (WebGPU)', async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const shader = p5.baseMaterialShader().modify(() => { + p5.randomSeed(7); + p5.getPixelInputs(inputs => { + let sum = p5.float(0.0); + for (let i = 0; i < 20; i++) { + sum = sum + p5.random(); + } + const avg = sum / 20; + inputs.color = [avg, avg, avg, 1.0]; + return inputs; + }); + }, { p5 }); + + p5.background(0); + p5.noStroke(); + p5.shader(shader); + p5.plane(50, 50); + await screenshot(); + }); }); + visualSuite('filters', function() { const setupSketch = async (p5) => { await p5.createCanvas(50, 50, p5.WEBGPU); @@ -494,6 +587,28 @@ visualSuite("WebGPU", function () { }, ); + visualTest( + "Framebuffer with depth disabled", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const fbo = p5.createFramebuffer({ width: 50, height: 50, depth: false }); + + fbo.draw(() => { + p5.background(0, 0, 200); + p5.fill(255, 200, 0); + p5.noStroke(); + p5.circle(0, 0, 30); + }); + + p5.background(50); + p5.texture(fbo); + p5.noStroke(); + p5.plane(50, 50); + + await screenshot(); + }, + ); + visualTest( "Fixed-size framebuffer after manual resize", async function (p5, screenshot) { @@ -535,6 +650,21 @@ visualSuite("WebGPU", function () { ); }); + visualSuite("Rendering attributes", function () { + visualTest( + "noSmooth() does not crash and disables antialiasing", + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + await p5.noSmooth(); + p5.background(0); + p5.fill(255); + p5.noStroke(); + p5.circle(0, 0, 30); + await screenshot(); + }, + ); + }); + visualSuite("Clipping", function () { visualTest( "Basic clipping with circles", @@ -996,4 +1126,513 @@ visualSuite("WebGPU", function () { await screenshot(); }); }); + + visualSuite('Compute shaders', function() { + visualTest( + 'Storage buffer (float array) can be read in a vertex shader for instanced rendering', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Positions for 3 spheres: (-15,0), (0,0), (15,0) + const positions = p5.createStorage([-15, 0, 0, 0, 15, 0]); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const posData = p5.uniformStorage(); + p5.getWorldInputs((inputs) => { + const idx = p5.instanceID(); + inputs.position.x += posData[idx * 2]; + inputs.position.y += posData[idx * 2 + 1]; + return inputs; + }); + }, { p5 }); + sphereShader.setUniform('posData', positions); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 3); + + await screenshot(); + } + ); + + visualTest( + 'Compute shader writes float values to storage buffer, vertex shader reads them', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Start with zeros; compute shader will write [20, -10] + const offset = p5.createStorage(2); + + const computeShader = p5.buildComputeShader(() => { + const buf = p5.uniformStorage(); + buf[0] = 20; + buf[1] = -10; + }, { p5 }); + computeShader.setUniform('buf', offset); + p5.compute(computeShader, 1); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage(); + p5.getWorldInputs((inputs) => { + inputs.position.x += buf[0]; + inputs.position.y += buf[1]; + return inputs; + }); + }, { p5 }); + sphereShader.setUniform('buf', offset); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 1); + + await screenshot(); + } + ); + + visualTest( + 'Compute shader reads and transforms float array values', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Initialize with [10, 0] - compute will double x to get [20, 0] + const buf = p5.createStorage([10, 0]); + + const computeShader = p5.buildComputeShader(() => { + const data = p5.uniformStorage(); + data[0] = data[0] * 2; + }, { p5 }); + computeShader.setUniform('data', buf); + p5.compute(computeShader, 1); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const data = p5.uniformStorage(); + p5.getWorldInputs((inputs) => { + inputs.position.x += data[0]; + inputs.position.y += data[1]; + return inputs; + }); + }, { p5 }); + sphereShader.setUniform('data', buf); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 1); + + await screenshot(); + } + ); + + + visualTest( + 'Struct storage buffer fields can be read in a vertex shader for instanced rendering', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Three particles at known positions: left, center, right + const particles = p5.createStorage([ + { position: [-15, 0] }, + { position: [0, 0] }, + { position: [15, 0] }, + ]); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', particles); + p5.getWorldInputs((inputs) => { + const p = buf[p5.instanceID()].position; + inputs.position.x += p.x; + inputs.position.y += p.y; + return inputs; + }); + }, { p5, particles }); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 3); + + await screenshot(); + } + ); + + visualTest( + 'Struct storage buffer fields can use p5.Vector values', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Three particles at known positions: left, center, right + const particles = p5.createStorage([ + { position: p5.createVector(-15, 0) }, + { position: p5.createVector(0, 0) }, + { position: p5.createVector(15, 0) }, + ]); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', particles); + p5.getWorldInputs((inputs) => { + const p = buf[p5.instanceID()].position; + inputs.position.x += p.x; + inputs.position.y += p.y; + return inputs; + }); + }, { p5, particles }); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 3); + + await screenshot(); + } + ); + + visualTest( + 'Struct storage buffer fields can be read using an inline schema template', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Same layout as above but schema is declared inline rather than via the buffer + const particles = p5.createStorage([ + { position: [-15, 0] }, + { position: [0, 0] }, + { position: [15, 0] }, + ]); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', { position: [0, 0] }); + p5.getWorldInputs((inputs) => { + const p = buf[p5.instanceID()].position; + inputs.position.x += p.x; + inputs.position.y += p.y; + return inputs; + }); + }, { p5 }); + sphereShader.setUniform('buf', particles); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 3); + + await screenshot(); + } + ); + + visualTest( + 'Compute shader writes to struct storage fields, vertex shader reads them', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + const particles = p5.createStorage([ + { position: [0, 0] }, + ]); + + const computeShader = p5.buildComputeShader(() => { + const buf = p5.uniformStorage('buf', particles); + buf[p5.index.x].position = [15, -10]; + }, { p5, particles }); + p5.compute(computeShader, 1); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', particles); + p5.getWorldInputs((inputs) => { + const pos = buf[0].position; + inputs.position.x += pos.x; + inputs.position.y += pos.y; + return inputs; + }); + }, { p5, particles }); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 1); + + await screenshot(); + } + ); + + visualTest( + 'Compute shader reads and updates struct fields (position += velocity)', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + const particles = p5.createStorage([ + { position: [0, 0], velocity: [15, -10] }, + ]); + + const computeShader = p5.buildComputeShader(() => { + const buf = p5.uniformStorage('buf', particles); + const idx = p5.index.x; + buf[idx].position = buf[idx].position + buf[idx].velocity; + }, { p5, particles }); + p5.compute(computeShader, 1); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', particles); + p5.getWorldInputs((inputs) => { + const pos = buf[0].position; + inputs.position.x += pos.x; + inputs.position.y += pos.y; + return inputs; + }); + }, { p5, particles }); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 1); + + await screenshot(); + } + ); + + visualTest( + 'Compute shader updates struct fields via intermediate struct variable', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + const particles = p5.createStorage([ + { position: [0, 0], velocity: [15, -10] }, + ]); + + // Store the struct element proxy in a variable and assign through it + const computeShader = p5.buildComputeShader(() => { + const buf = p5.uniformStorage('buf', particles); + const idx = p5.index.x; + const entry = buf[idx]; + entry.position = entry.position + entry.velocity; + }, { p5, particles }); + p5.compute(computeShader, 1); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', particles); + p5.getWorldInputs((inputs) => { + const pos = buf[0].position; + inputs.position.x += pos.x; + inputs.position.y += pos.y; + return inputs; + }); + }, { p5, particles }); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 1); + + await screenshot(); + } + ); + + visualTest( + 'Compute shader updates struct fields via intermediate field variable', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + const particles = p5.createStorage([ + { position: [0, 0], velocity: [15, -10] }, + ]); + + // Store a field value in an intermediate variable, update it, write it back + const computeShader = p5.buildComputeShader(() => { + const buf = p5.uniformStorage('buf', particles); + const idx = p5.index.x; + let pos = buf[idx].position; + pos = pos + buf[idx].velocity; + buf[idx].position = pos; + }, { p5, particles }); + p5.compute(computeShader, 1); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', particles); + p5.getWorldInputs((inputs) => { + const pos = buf[0].position; + inputs.position.x += pos.x; + inputs.position.y += pos.y; + return inputs; + }); + }, { p5, particles }); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 1); + + await screenshot(); + } + ); + + visualTest( + 'Compute shader writes a whole struct element as an object literal', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + const particles = p5.createStorage([ + { position: [0, 0], velocity: [15, -10] }, + ]); + + const computeShader = p5.buildComputeShader(() => { + const buf = p5.uniformStorage('buf', particles); + const idx = p5.index.x; + let pos = buf[idx].position; + let vel = buf[idx].velocity; + pos = pos + vel; + buf[idx] = { position: pos, velocity: vel }; + }, { p5, particles }); + p5.compute(computeShader, 1); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', particles); + p5.getWorldInputs((inputs) => { + const pos = buf[0].position; + inputs.position.x += pos.x; + inputs.position.y += pos.y; + return inputs; + }); + }, { p5, particles }); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 1); + + await screenshot(); + } + ); + + visualTest( + 'Compute shader assigns to a swizzle of a struct vector field', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + const particles = p5.createStorage([ + { position: [15, 10] }, + ]); + + // Negate position.y via swizzle assignment + const computeShader = p5.buildComputeShader(() => { + const buf = p5.uniformStorage('buf', particles); + const idx = p5.index.x; + buf[idx].position.y *= -1; + }, { p5, particles }); + p5.compute(computeShader, 1); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', particles); + p5.getWorldInputs((inputs) => { + const pos = buf[0].position; + inputs.position.x += pos.x; + inputs.position.y += pos.y; + return inputs; + }); + }, { p5, particles }); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 1); + + await screenshot(); + } + ); + + visualTest( + 'Compute shader assigns to a swizzle of a struct vector field inside an if statement', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + const particles = p5.createStorage([ + { position: [0, 0], velocity: [5, 5] }, + ]); + + // Move by velocity, then negate velocity.y if position.y > 0. + // After 1st run: position=[5,5], velocity=[5,-5]. + // After 2nd run: position=[10,0], velocity=[5,-5]. + const computeShader = p5.buildComputeShader(() => { + const buf = p5.uniformStorage('buf', particles); + const idx = p5.index.x; + buf[idx].position += buf[idx].velocity; + if (buf[idx].position.y > 0) { + buf[idx].velocity.y *= -1; + } + }, { p5, particles }); + p5.compute(computeShader, 1); + p5.compute(computeShader, 1); + + const sphereShader = p5.baseMaterialShader().modify(() => { + const buf = p5.uniformStorage('buf', particles); + p5.getWorldInputs((inputs) => { + const pos = buf[0].position; + inputs.position.x += pos.x; + inputs.position.y += pos.y; + return inputs; + }); + }, { p5, particles }); + + const geo = p5.buildGeometry(() => p5.sphere(5)); + p5.background(200); + p5.noStroke(); + p5.fill(255, 0, 0); + p5.shader(sphereShader); + p5.model(geo, 1); + + await screenshot(); + } + ); + }); + + visualSuite('Feedback', function() { + visualTest( + 'Drawing accumulates across frames when background is set in setup', + async function(p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + + // Set an initial background before the draw loop starts. + // This should persist into the first draw frame. + p5.background('red'); + + return new Promise(resolve => { + let frame = 0; + p5.draw = function() { + // Draw circles without clearing, so they accumulate + p5.noStroke(); + p5.fill('blue'); + p5.circle(-15 + frame * 15, 0, 10); + frame++; + if (frame >= 3) { + p5.noLoop(); + screenshot().then(resolve); + } + }; + p5.loop(); + }); + } + ); + }); }); diff --git a/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/000.png b/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/000.png new file mode 100644 index 0000000000..e5ec27fc33 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/metadata.json b/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/2D Shapes/rect() rounded into a circle/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/000.png b/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/000.png new file mode 100644 index 0000000000..c92e8ba0a8 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/metadata.json b/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/Opacity/noTint() before image() does not throw/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/000.png b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/000.png new file mode 100644 index 0000000000..88030f07cc Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/metadata.json b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in 2d mode/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/000.png b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/000.png new file mode 100644 index 0000000000..88030f07cc Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/metadata.json b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/filter/Multiple filter passes work correctly on a p5.Graphics in webgl mode/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/000.png b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/000.png new file mode 100644 index 0000000000..eb51606a3d Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/metadata.json b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in 2d mode/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/000.png b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/000.png new file mode 100644 index 0000000000..eb51606a3d Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/metadata.json b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/filter/Transparent background colors are correct in webgl mode/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/000.png b/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/000.png new file mode 100644 index 0000000000..86eb4ba339 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/metadata.json b/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/instanced randering/instanceID in fragment hook colors instances/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/000.png new file mode 100644 index 0000000000..223241d6cd Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/lerp maps to mix in strands context/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/000.png new file mode 100644 index 0000000000..223241d6cd Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/mix produces same result as lerp in strands/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/000.png new file mode 100644 index 0000000000..92b7e4956a Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/random() colors a basic shader/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/000.png new file mode 100644 index 0000000000..ada712c7c5 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Color sets final color/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/000.png new file mode 100644 index 0000000000..eee8dd8f28 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/setUniform with p5.Vector offsets position/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/000.png new file mode 100644 index 0000000000..460f144e68 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/texCoord is available in getFinalColor/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/texture()/on a rect with rounded corners/000.png b/test/unit/visual/screenshots/WebGL/texture()/on a rect with rounded corners/000.png index 5e400290b2..a248b08ebb 100644 Binary files a/test/unit/visual/screenshots/WebGL/texture()/on a rect with rounded corners/000.png and b/test/unit/visual/screenshots/WebGL/texture()/on a rect with rounded corners/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/000.png new file mode 100644 index 0000000000..2bf313d4bf Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field inside an if statement/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/000.png new file mode 100644 index 0000000000..0b6d74a1c5 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader assigns to a swizzle of a struct vector field/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/000.png new file mode 100644 index 0000000000..cf4799e76b Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and transforms float array values/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/000.png new file mode 100644 index 0000000000..a561306a22 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader reads and updates struct fields (position += velocity)/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/000.png new file mode 100644 index 0000000000..a561306a22 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate field variable/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/000.png new file mode 100644 index 0000000000..a561306a22 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader updates struct fields via intermediate struct variable/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/000.png new file mode 100644 index 0000000000..a561306a22 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes a whole struct element as an object literal/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/000.png new file mode 100644 index 0000000000..7e7af5583a Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes float values to storage buffer, vertex shader reads them/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/000.png new file mode 100644 index 0000000000..a561306a22 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Compute shader writes to struct storage fields, vertex shader reads them/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/000.png new file mode 100644 index 0000000000..70a5a6e04f Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Storage buffer (float array) can be read in a vertex shader for instanced rendering/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/000.png new file mode 100644 index 0000000000..70a5a6e04f Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read in a vertex shader for instanced rendering/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/000.png new file mode 100644 index 0000000000..70a5a6e04f Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can be read using an inline schema template/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/000.png b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/000.png new file mode 100644 index 0000000000..70a5a6e04f Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/metadata.json b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Compute shaders/Struct storage buffer fields can use p5.Vector values/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/000.png b/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/000.png new file mode 100644 index 0000000000..8f406fd13d Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/metadata.json b/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Feedback/Drawing accumulates across frames when background is set in setup/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/000.png b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/000.png new file mode 100644 index 0000000000..20fb23ed5a Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/metadata.json b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Framebuffers/Framebuffer with depth disabled/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/000.png b/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/000.png new file mode 100644 index 0000000000..5c5063d118 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/metadata.json b/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Rendering attributes/noSmooth() does not crash and disables antialiasing/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/000.png new file mode 100644 index 0000000000..2a307cdf64 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Shaders/filter shaders can sample a texture inside a conditional branch/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/000.png new file mode 100644 index 0000000000..1e99dcacc9 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Shaders/instanceID in fragment hook colors instances (WebGPU)/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/000.png new file mode 100644 index 0000000000..be4a035dbf Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Shaders/random() colors a basic shader (WebGPU)/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/000.png b/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/000.png new file mode 100644 index 0000000000..5e19d4c988 Binary files /dev/null and b/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/000.png differ diff --git a/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/metadata.json b/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGPU/Shaders/random() in a fragment loop averages to gray (WebGPU)/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js index f921fefdbf..b761665d9c 100644 --- a/test/unit/visual/visualTest.js +++ b/test/unit/visual/visualTest.js @@ -64,7 +64,6 @@ export function visualSuite( suiteFn(name, () => { let lastShiftThreshold; let lastPrefix; - let lastDeviceRatio = window.devicePixelRatio; beforeAll(() => { lastPrefix = namePrefix; namePrefix += escapeName(name) + '/'; @@ -72,16 +71,12 @@ export function visualSuite( if (newShiftThreshold !== undefined) { shiftThreshold = newShiftThreshold; } - - // Force everything to be 1x - window.devicePixelRatio = 1; }); callback(); afterAll(() => { namePrefix = lastPrefix; - window.devicePixelRatio = lastDeviceRatio; shiftThreshold = lastShiftThreshold; }); }); @@ -398,9 +393,12 @@ export function visualTest( suiteFn(testName, function() { let name; let myp5; + let lastDeviceRatio = window.devicePixelRatio; beforeAll(function() { name = namePrefix + escapeName(testName); + // Force everything to be 1x + window.devicePixelRatio = 1; return new Promise(res => { myp5 = new p5(function(p) { p.setup = function() { @@ -411,6 +409,7 @@ export function visualTest( }); afterAll(function() { + window.devicePixelRatio = lastDeviceRatio; myp5.remove(); }); diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js index b42562051f..079b8000f6 100644 --- a/test/unit/webgl/p5.RendererGL.js +++ b/test/unit/webgl/p5.RendererGL.js @@ -89,6 +89,33 @@ suite('p5.RendererGL', function() { }); }); + suite('p5.strands', function() { + test('a uniform whose name matches a hook parameter name does not break', function() { + myp5.createCanvas(10, 10, myp5.WEBGL); + myp5.pixelDensity(1); + + // 'color' is the GLSL parameter name of the getFinalColor hook's first argument. + // Creating a uniform with the same name used to cause a GLSL name clash. + const myShader = myp5.baseColorShader().modify(() => { + const color = myp5.uniformFloat('color', 0.5); + myp5.finalColor.begin(); + myp5.finalColor.set([color, color, color, 1]); + myp5.finalColor.end(); + }, { myp5 }); + + myp5.background(0); + myp5.noStroke(); + myp5.shader(myShader); + myp5.plane(myp5.width, myp5.height); + + const pixel = myp5.get(5, 5); + assert.approximately(pixel[0], 128, 1); + assert.equal(pixel[0], pixel[1]); + assert.equal(pixel[1], pixel[2]); + assert.equal(pixel[3], 255); + }); + }); + suite('texture binding', function() { test('setting a custom texture works', function() { myp5.createCanvas(10, 10, myp5.WEBGL); @@ -1490,33 +1517,56 @@ suite('p5.RendererGL', function() { }); suite('tint() in WEBGL mode', function() { - test('default tint value is set and not null', function() { + test('default tint value', function() { myp5.createCanvas(100, 100, myp5.WEBGL); - assert.deepEqual(myp5._renderer.states.tint, [255, 255, 255, 255]); + assert.deepEqual( + myp5._renderer.states.tint?._getRGBA([255, 255, 255, 255]) ?? [255, 255, 255, 255], + [255, 255, 255, 255] + ); }); + + test('tint value is modified correctly when tint() is called', function() { + + function assertColorEq(tint, colArray) { + assert.deepEqual(tint._getRGBA([255, 255, 255, 255]), colArray); + } + myp5.createCanvas(100, 100, myp5.WEBGL); + myp5.tint(0, 153, 204, 126); - assert.deepEqual(myp5._renderer.states.tint, [0, 153, 204, 126]); + assertColorEq(myp5._renderer.states.tint, [0, 153, 204, 126]); + myp5.tint(100, 120, 140); - assert.deepEqual(myp5._renderer.states.tint, [100, 120, 140, 255]); + assertColorEq(myp5._renderer.states.tint, [100, 120, 140, 255]); + myp5.tint('violet'); - assert.deepEqual(myp5._renderer.states.tint, [238, 130, 238, 255]); + // Note that in WEBGL mode, we don't convert color strings to arrays until the shader, + // so the tint state is still the string 'violet' at this point, not the array [238, 130, 238, 255]. + //assertDeepEqualColor(myp5._renderer.states.tint, [238, 130, 238, 255]); + assert.equal(myp5._renderer.states.tint, 'violet'); + myp5.tint(100); - assert.deepEqual(myp5._renderer.states.tint, [100, 100, 100, 255]); + assertColorEq(myp5._renderer.states.tint, [100, 100, 100, 255]); + myp5.tint(100, 126); - assert.deepEqual(myp5._renderer.states.tint, [100, 100, 100, 126]); + assertColorEq(myp5._renderer.states.tint, [100, 100, 100, 126]); + myp5.tint([100, 126, 0, 200]); - assert.deepEqual(myp5._renderer.states.tint, [100, 126, 0, 200]); + assertColorEq(myp5._renderer.states.tint, [100, 126, 0, 200]); + myp5.tint([100, 126, 0]); - assert.deepEqual(myp5._renderer.states.tint, [100, 126, 0, 255]); + assertColorEq(myp5._renderer.states.tint, [100, 126, 0, 255]); + myp5.tint([100]); - assert.deepEqual(myp5._renderer.states.tint, [100, 100, 100, 255]); + assertColorEq(myp5._renderer.states.tint, [100, 100, 100, 255]); + myp5.tint([100, 126]); - assert.deepEqual(myp5._renderer.states.tint, [100, 100, 100, 126]); + assertColorEq(myp5._renderer.states.tint, [100, 100, 100, 126]); + myp5.tint(myp5.color(255, 204, 0)); - assert.deepEqual(myp5._renderer.states.tint, [255, 204, 0, 255]); + assertColorEq(myp5._renderer.states.tint, [255, 204, 0, 255]); }); test('tint should be reset after draw loop', function() { @@ -1533,7 +1583,8 @@ suite('p5.RendererGL', function() { }; }); }).then(function(_tint) { - assert.deepEqual(_tint, [255, 255, 255, 255]); + assert.deepEqual(_tint?._getRGBA([255, 255, 255, 255]) ?? [255, 255, 255, 255], + [255, 255, 255, 255]); }); }); }); @@ -2010,6 +2061,98 @@ suite('p5.RendererGL', function() { [-10, 0, 10] ); }); + + suite('large tessellation guard', function() { + test('prompts user before tessellating >50k vertices', function() { + const renderer = myp5.createCanvas(10, 10, myp5.WEBGL); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const tessSpy = vi.spyOn( + renderer.shapeBuilder, + '_tesselateShape' + ).mockImplementation(() => {}); + + myp5.beginShape(); + for (let i = 0; i < 60000; i++) { + myp5.vertex(i % 100, Math.floor(i / 100), 0); + } + myp5.endShape(); + + expect(confirmSpy).toHaveBeenCalled(); + expect(confirmSpy.mock.calls[0][0]).toContain('60000'); + expect(tessSpy).not.toHaveBeenCalled(); + + confirmSpy.mockRestore(); + tessSpy.mockRestore(); + }); + + test('only prompts once when user approves large tessellation', function() { + const renderer = myp5.createCanvas(10, 10, myp5.WEBGL); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const tessSpy = vi.spyOn( + renderer.shapeBuilder, + '_tesselateShape' + ).mockImplementation(() => {}); + + myp5.beginShape(); + for (let i = 0; i < 60000; i++) { + myp5.vertex(i % 100, Math.floor(i / 100), 0); + } + myp5.endShape(); + + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(renderer._largeTessellationAcknowledged).toBe(true); + + myp5.beginShape(); + for (let i = 0; i < 60000; i++) { + myp5.vertex(i % 100, Math.floor(i / 100), 0); + } + myp5.endShape(); + + expect(confirmSpy).toHaveBeenCalledTimes(1); + + confirmSpy.mockRestore(); + tessSpy.mockRestore(); + }); + + test('skips prompt when p5.disableFriendlyErrors is true', function() { + const renderer = myp5.createCanvas(10, 10, myp5.WEBGL); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const tessSpy = vi.spyOn( + renderer.shapeBuilder, + '_tesselateShape' + ).mockImplementation(() => {}); + p5.disableFriendlyErrors = true; + + myp5.beginShape(); + for (let i = 0; i < 60000; i++) { + myp5.vertex(i % 100, Math.floor(i / 100), 0); + } + myp5.endShape(); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(tessSpy).toHaveBeenCalled(); + + p5.disableFriendlyErrors = false; + confirmSpy.mockRestore(); + tessSpy.mockRestore(); + }); + + test('works normally for <50k vertices', function() { + const renderer = myp5.createCanvas(10, 10, myp5.WEBGL); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + myp5.beginShape(); + myp5.vertex(-10, -10, 0); + myp5.vertex(10, -10, 0); + myp5.vertex(10, 10, 0); + myp5.vertex(-10, 10, 0); + myp5.endShape(myp5.CLOSE); + + expect(confirmSpy).not.toHaveBeenCalled(); + + confirmSpy.mockRestore(); + }); + }); }); suite('color interpolation', function() { @@ -2975,5 +3118,56 @@ suite('p5.RendererGL', function() { myp5.model(geom); expect(myp5.get(5, 5)).toEqual([255, 0, 0, 255]); }); + test('does not throw with a large number of vertices', function() { + myp5.createCanvas(10, 10, myp5.WEBGL); + // Enough triangles to exceed the ~65k argument limit of Function.prototype.apply, + // which would cause a stack overflow if vertices were spread into push() calls. + const numTriangles = 30000; + expect(() => { + myp5.buildGeometry(() => { + myp5.beginShape(myp5.TRIANGLES); + for (let i = 0; i < numTriangles; i++) { + myp5.vertex(0, 0, 0); + myp5.vertex(1, 0, 0); + myp5.vertex(0, 1, 0); + } + myp5.endShape(); + }); + }).not.toThrow(); + }); + }); + + suite('fontWidth', function() { + test('respects textSize changes across push/pop', async function() { + myp5.createCanvas(100, 100, myp5.WEBGL); + const font = await myp5.loadFont('test/unit/assets/acmesa.ttf'); + + myp5.push(); + myp5.textFont(font); + myp5.textSize(12); + myp5.push(); + myp5.textSize(20); + const widthAt20 = myp5.fontWidth('X'); + myp5.pop(); + const widthAt12 = myp5.fontWidth('X'); + myp5.pop(); + + expect(widthAt20).toBeGreaterThan(widthAt12); + }); + + test('fontWidth restores correctly when font is unset inside push/pop', async function() { + myp5.createCanvas(100, 100, myp5.WEBGL); + const font = await myp5.loadFont('test/unit/assets/acmesa.ttf'); + myp5.textFont(font); + myp5.textSize(12); + myp5.push(); + myp5.textFont('sans-serif'); // unset loaded font + myp5.pop(); + // After pop, should be back to size 12 with loaded font + const widthAfterPop = myp5.fontWidth('X'); + myp5.textSize(20); + const widthAt20 = myp5.fontWidth('X'); + expect(widthAfterPop).toBeLessThan(widthAt20); + }); }); }); diff --git a/test/unit/webgl/p5.Shader.js b/test/unit/webgl/p5.Shader.js index b0f04a78bc..786540cafa 100644 --- a/test/unit/webgl/p5.Shader.js +++ b/test/unit/webgl/p5.Shader.js @@ -318,7 +318,7 @@ suite('p5.Shader', function() { uniforms: { 'sampler2D myTex': null }, - 'vec4 getFinalColor': `(vec4 c) { + 'vec4 getFinalColor': `(vec4 c, vec2 texCoord) { return getTexture(myTex, vec2(0.,0.)); }` }); @@ -459,6 +459,21 @@ suite('p5.Shader', function() { }).not.toThrowError(); }); + test('buildFilterShader can use numeric constants from scope', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + const constants = { val: 100 }; + const myShader = myp5.buildFilterShader(({ constants }) => { + filterColor.begin(); + let c = 0; + c += constants.val / 255; + filterColor.set([c, c, c, 1]); + filterColor.end(); + }, { constants }); + expect(() => { + myp5.filter(myShader); + }).not.toThrowError(); + }); + test('buildMaterialShader forwards scope to modify', () => { myp5.createCanvas(5, 5, myp5.WEBGL); expect(() => { @@ -495,6 +510,128 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca }); + test('map() works inside a strands modify callback', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const v = myp5.map(0.25, 0, 1, 0, 1); + inputs.color = [v, v, v, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 64, 5); + assert.approximately(pixelColor[1], 64, 5); + assert.approximately(pixelColor[2], 64, 5); + }); + + test('map() with withinBounds clamps the result inside strands', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const v = myp5.map(2.0, 0, 1, 0, 1, true); + inputs.color = [v, v, v, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 255, 5); + assert.approximately(pixelColor[1], 255, 5); + assert.approximately(pixelColor[2], 255, 5); + }); + + test('map() shrinks a wider input range into a narrower output range', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const v = myp5.map(5, 0, 10, 0, 1); + inputs.color = [v, v, v, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 128, 5); + assert.approximately(pixelColor[1], 128, 5); + assert.approximately(pixelColor[2], 128, 5); + }); + + test('map() handles offset output ranges correctly', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const v = myp5.map(0.5, 0, 1, 0.2, 0.8); + inputs.color = [v, v, v, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 128, 5); + assert.approximately(pixelColor[1], 128, 5); + assert.approximately(pixelColor[2], 128, 5); + }); + + test('map() handles a negative input range', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const v = myp5.map(0, -1, 1, 0, 1); + inputs.color = [v, v, v, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 128, 5); + assert.approximately(pixelColor[1], 128, 5); + assert.approximately(pixelColor[2], 128, 5); + }); + + test('map() remaps texCoord.x into a horizontal gradient', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const v = myp5.map(inputs.texCoord.x, 0, 1, 0.2, 0.8); + inputs.color = [v, v, v, 1.0]; + return inputs; + }); + }, { myp5 }); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const left = myp5.get(2, 25); + const middle = myp5.get(25, 25); + const right = myp5.get(47, 25); + assert.approximately(left[0], 51, 10); + assert.approximately(middle[0], 128, 10); + assert.approximately(right[0], 204, 10); + }); + test('handle custom uniform names with automatic values', () => { myp5.createCanvas(50, 50, myp5.WEBGL); const testShader = myp5.baseMaterialShader().modify(() => { @@ -542,6 +679,107 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca assert.approximately(pixelColor[2], 153, 5); }); + suite('array indexing on non-storage vectors (#8756)', () => { + afterEach(() => { + mockUserError.mockClear(); + }); + + test('indexing into array returned from helper function works in WebGL', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + const myShader = myp5.baseMaterialShader().modify(() => { + const brightness = myp5.uniformFloat(); + function getArr() { + return [1, 2]; + } + const arr = getArr(); + myp5.getPixelInputs(inputs => { + inputs.color = [arr[0] * brightness, arr[1], 0, 1]; + return inputs; + }); + }, { myp5 }); + expect(() => { + myp5.shader(myShader); + myp5.plane(myp5.width, myp5.height); + }).not.toThrowError(); + }); + + test('inline literal indexing [1, 2][0] works in WebGL', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + const myShader = myp5.baseMaterialShader().modify(() => { + const brightness = myp5.uniformFloat(); + myp5.getPixelInputs(inputs => { + inputs.color = [[1, 2][0] * brightness, 0, 0, 1]; + return inputs; + }); + }, { myp5 }); + expect(() => { + myp5.shader(myShader); + myp5.plane(myp5.width, myp5.height); + }).not.toThrowError(); + }); + + test('array literal with 1 element throws descriptive error in WebGL', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + expect(() => { + myp5.baseMaterialShader().modify(() => { + const arr = [1]; + myp5.getPixelInputs(inputs => { + inputs.color = [arr[0], 0, 0, 1]; + return inputs; + }); + }, { myp5 }); + }).toThrowError('and must have 2-4 elements (got 1)'); + }); + + test('array literal with 5 elements throws descriptive error in WebGL', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + expect(() => { + myp5.baseMaterialShader().modify(() => { + const arr = [1, 2, 3, 4, 5]; + myp5.getPixelInputs(inputs => { + inputs.color = [arr[0], 0, 0, 1]; + return inputs; + }); + }, { myp5 }); + }).toThrowError('and must have 2-4 elements (got 5)'); + }); + + test('valid array lengths 2, 3, 4 work in WebGL', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + expect(() => { + const s2 = myp5.baseMaterialShader().modify(() => { + const arr = [1, 2]; + myp5.getPixelInputs(inputs => { + inputs.color = [arr[0], 0, 0, 1]; + return inputs; + }); + }, { myp5 }); + myp5.shader(s2); + myp5.plane(myp5.width, myp5.height); + + const s3 = myp5.baseMaterialShader().modify(() => { + const arr = [1, 2, 3]; + myp5.getPixelInputs(inputs => { + inputs.color = [arr[0], 0, 0, 1]; + return inputs; + }); + }, { myp5 }); + myp5.shader(s3); + myp5.plane(myp5.width, myp5.height); + + const s4 = myp5.baseMaterialShader().modify(() => { + const arr = [1, 2, 3, 4]; + myp5.getPixelInputs(inputs => { + inputs.color = [arr[0], 0, 0, 1]; + return inputs; + }); + }, { myp5 }); + myp5.shader(s4); + myp5.plane(myp5.width, myp5.height); + }).not.toThrowError(); + }); + }); + suite('if statement conditionals', () => { test('handle simple if statement with true condition', () => { myp5.createCanvas(50, 50, myp5.WEBGL); @@ -1204,6 +1442,55 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca }); }); + suite('ternary expressions', () => { + test('ternary changes color based on left/right side of canvas', () => { + myp5.createCanvas(50, 25, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + inputs.color = inputs.texCoord.x > 0.5 ? [1, 0, 0, 1] : [0, 0, 1, 1]; + return inputs; + }); + }, { myp5 }); + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const leftPixel = myp5.get(12, 12); + assert.approximately(leftPixel[0], 0, 5); + assert.approximately(leftPixel[1], 0, 5); + assert.approximately(leftPixel[2], 255, 5); + + const rightPixel = myp5.get(37, 12); + assert.approximately(rightPixel[0], 255, 5); + assert.approximately(rightPixel[1], 0, 5); + assert.approximately(rightPixel[2], 0, 5); + }); + + test('ternary with scalar values', () => { + myp5.createCanvas(50, 25, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const brightness = inputs.texCoord.x > 0.5 ? 1.0 : 0.0; + inputs.color = [brightness, brightness, brightness, 1]; + return inputs; + }); + }, { myp5 }); + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const leftPixel = myp5.get(12, 12); + assert.approximately(leftPixel[0], 0, 5); + assert.approximately(leftPixel[1], 0, 5); + assert.approximately(leftPixel[2], 0, 5); + + const rightPixel = myp5.get(37, 12); + assert.approximately(rightPixel[0], 255, 5); + assert.approximately(rightPixel[1], 255, 5); + assert.approximately(rightPixel[2], 255, 5); + }); + }); + suite('for loop statements', () => { test('handle simple for loop with known iteration count', () => { myp5.createCanvas(50, 50, myp5.WEBGL); @@ -2135,6 +2422,168 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca assert.approximately(pixelColor[2], 0, 5); }); + test('handle uniformFloat with control flow in callback', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + const testShader = myp5.baseFilterShader().modify(() => { + // Uniform callback with an if-statement and multiple return paths + const pastOneSecond = myp5.uniformFloat(() => { + if (myp5.frameCount > 1000) { + return 1; + } + return 0; + }); + + myp5.filterColor.begin(); + myp5.filterColor.set(myp5.mix([1, 0, 0, 1], [0, 1, 0, 1], pastOneSecond)); + myp5.filterColor.end(); + }, { myp5 }); + + myp5.background(255, 255, 255); + myp5.filter(testShader); + + // frameCount <= 1000 so pastOneSecond = 0, mix returns [1, 0, 0, 1] = red + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 255, 5); + assert.approximately(pixelColor[1], 0, 5); + assert.approximately(pixelColor[2], 0, 5); + }); + + test('handle uniformFloat with for loop in callback', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + const testShader = myp5.baseFilterShader().modify(() => { + // Uniform callback with a for loop accumulating a value + const brightness = myp5.uniformFloat(() => { + let sum = 0; + for (let i = 0; i < 3; i++) { + sum += i; + } + return sum / 10; // 0+1+2=3, 3/10=0.3 + }); + + myp5.filterColor.begin(); + myp5.filterColor.set([brightness, 0, 0, 1]); + myp5.filterColor.end(); + }, { myp5 }); + + myp5.background(255, 255, 255); + myp5.filter(testShader); + + // brightness = 0.3, so red channel = 0.3 * 255 β‰ˆ 76 + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 0.3 * 255, 5); + assert.approximately(pixelColor[1], 0, 5); + assert.approximately(pixelColor[2], 0, 5); + }); + + test('handle uniformFloat with sub-function call in callback', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + const testShader = myp5.baseFilterShader().modify(() => { + // Uniform callback that calls a sub-function + const brightness = myp5.uniformFloat(() => { + const getValue = () => 0.6; + return getValue(); + }); + + myp5.filterColor.begin(); + myp5.filterColor.set([brightness, 0, 0, 1]); + myp5.filterColor.end(); + }, { myp5 }); + + myp5.background(255, 255, 255); + myp5.filter(testShader); + + // brightness = 0.6, so red channel = 0.6 * 255 β‰ˆ 153 + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 0.6 * 255, 5); + assert.approximately(pixelColor[1], 0, 5); + assert.approximately(pixelColor[2], 0, 5); + }); + + test('handle uniformFloat with control flow in non-inline callback', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + const testShader = myp5.baseFilterShader().modify(() => { + function pastOneSecondValue() { + if (myp5.frameCount > 1000) { + return 1; + } + return 0; + } + const pastOneSecond = myp5.uniformFloat(pastOneSecondValue); + + myp5.filterColor.begin(); + myp5.filterColor.set(myp5.mix([1, 0, 0, 1], [0, 1, 0, 1], pastOneSecond)); + myp5.filterColor.end(); + }, { myp5 }); + + myp5.background(255, 255, 255); + myp5.filter(testShader); + + // frameCount <= 1000 so pastOneSecond = 0, mix returns [1, 0, 0, 1] = red + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 255, 5); + assert.approximately(pixelColor[1], 0, 5); + assert.approximately(pixelColor[2], 0, 5); + }); + + test('handle uniformFloat with for loop in non-inline callback', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + const testShader = myp5.baseFilterShader().modify(() => { + function brightnessValue() { + let sum = 0; + for (let i = 0; i < 3; i++) { + sum += i; + } + return sum / 10; // 0+1+2=3, 3/10=0.3 + } + const brightness = myp5.uniformFloat(brightnessValue); + + myp5.filterColor.begin(); + myp5.filterColor.set([brightness, 0, 0, 1]); + myp5.filterColor.end(); + }, { myp5 }); + + myp5.background(255, 255, 255); + myp5.filter(testShader); + + // brightness = 0.3, so red channel = 0.3 * 255 β‰ˆ 76 + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 0.3 * 255, 5); + assert.approximately(pixelColor[1], 0, 5); + assert.approximately(pixelColor[2], 0, 5); + }); + + test('handle uniformFloat with sub-function call in non-inline callback', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + const testShader = myp5.baseFilterShader().modify(() => { + function getValue() { + return 0.6; + } + function brightnessValue() { + return getValue(); + } + const brightness = myp5.uniformFloat(brightnessValue); + + myp5.filterColor.begin(); + myp5.filterColor.set([brightness, 0, 0, 1]); + myp5.filterColor.end(); + }, { myp5 }); + + myp5.background(255, 255, 255); + myp5.filter(testShader); + + // brightness = 0.6, so red channel = 0.6 * 255 β‰ˆ 153 + const pixelColor = myp5.get(25, 25); + assert.approximately(pixelColor[0], 0.6 * 255, 5); + assert.approximately(pixelColor[1], 0, 5); + assert.approximately(pixelColor[2], 0, 5); + }); + test('handle false .set() in if with content afterwards with flat API', () => { myp5.createCanvas(50, 50, myp5.WEBGL); @@ -2162,6 +2611,20 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca }); suite('p5.strands error messages', () => { + const expectLoopProtectionError = run => { + let err; + + try { + run(); + } catch (e) { + err = e; + } + + assert.instanceOf(err, Error); + assert.include(err.message, 'Loop protection'); + assert.include(err.message, '// noprotect'); + }; + afterEach(() => { mockUserError.mockClear(); }); @@ -2193,7 +2656,9 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca }, { myp5 }); } catch (e) { /* expected */ } - assert.isAbove(mockUserError.mock.calls.length, 0, 'FES.userError should have been called'); + + + assert.isAbove(mockUserError.mock.calls.length, 0, 'FES.userError should have been called, btw: '+globalThis.FESCalled); const errMsg = mockUserError.mock.calls[0][1]; assert.include(errMsg, 'float3'); assert.include(errMsg, 'float4'); @@ -2215,5 +2680,61 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca assert.include(errMsg, 'Expected properties'); assert.include(errMsg, 'Received properties'); }); + + test('ternary with mismatched branch types shows both types in error', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + try { + myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + // float1 vs float4 - type mismatch + const val = inputs.texCoord.x > 0.5 ? myp5.float(1.0) : [1, 0, 0, 1]; + inputs.color = [val, val, val, 1]; + return inputs; + }); + }, { myp5 }); + } catch (e) { /* expected */ } + + assert.isAbove(mockUserError.mock.calls.length, 0, 'FES.userError should have been called'); + const errMsg = mockUserError.mock.calls[0][1]; + assert.include(errMsg, 'ternary'); + assert.include(errMsg, 'float1'); + assert.include(errMsg, 'float4'); + }); + + test('shows a helpful error for web editor loop protection', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + expectLoopProtectionError(() => { + myp5.baseFilterShader().modify(() => { + myp5.filterColor.begin(); + { + loopProtect.protect({ line: 11, reset: true }); + for (let i = 0; i < 10; i++) { + if (loopProtect.protect({ line: 11 })) { + break; + } + } + } + myp5.filterColor.set([1, 0, 0, 1]); + myp5.filterColor.end(); + }, { myp5 }); + }); + }); + + test('shows a helpful error for OpenProcessing loop protection', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + expectLoopProtectionError(() => { + myp5.baseFilterShader().modify(() => { + myp5.filterColor.begin(); + for (let i = 0; i < 10; i++) { + window.$OP && $OP.loopProtect({ line: 11, ch: 2 }); + } + myp5.filterColor.set([1, 0, 0, 1]); + myp5.filterColor.end(); + }, { myp5 }); + }); + }); }); }); diff --git a/test/unit/webgpu/p5.RendererWebGPU.js b/test/unit/webgpu/p5.RendererWebGPU.js index 1ed62563c5..3a21f174f6 100644 --- a/test/unit/webgpu/p5.RendererWebGPU.js +++ b/test/unit/webgpu/p5.RendererWebGPU.js @@ -127,6 +127,13 @@ suite('WebGPU p5.RendererWebGPU', function() { }); }); + suite('noSmooth()', function() { + test('disables antialiasing on the main canvas framebuffer', async function() { + await myp5.noSmooth(); + expect(myp5._renderer.mainFramebuffer.antialias).to.equal(false); + }); + }); + suite('Stability', function() { test('pixelDensity() after setAttributes() should not crash', async function() { // This test simulates the issue where a synchronous call (pixelDensity) @@ -160,4 +167,184 @@ suite('WebGPU p5.RendererWebGPU', function() { expect(myp5._renderer).to.exist; }); }); + + suite('StorageBuffer.read()', function() { + test('reads back float array data', async function() { + const input = new Float32Array([1, 2, 3, 4]); + const buf = myp5.createStorage(input); + + const result = await buf.read(); + + expect(result).to.be.instanceOf(Float32Array); + expect(result.length).to.equal(input.length); + for (let i = 0; i < input.length; i++) { + expect(result[i]).to.be.closeTo(input[i], 0.001); + } + }); + + test('reads back struct array data', async function() { + const input = [ + { x: 1.0, y: 2.0 }, + { x: 3.0, y: 4.0 }, + ]; + const buf = myp5.createStorage(input); + + const result = await buf.read(); + + expect(result).to.be.an('array'); + expect(result.length).to.equal(input.length); + for (let i = 0; i < input.length; i++) { + expect(result[i].x).to.be.closeTo(input[i].x, 0.001); + expect(result[i].y).to.be.closeTo(input[i].y, 0.001); + } + }); + + test('read after update returns new data', async function() { + const buf = myp5.createStorage(new Float32Array([10, 20, 30])); + const updated = new Float32Array([100, 200, 300]); + buf.update(updated); + + const result = await buf.read(); + + for (let i = 0; i < updated.length; i++) { + expect(result[i]).to.be.closeTo(updated[i], 0.001); + } + }); + + test('reads back struct with vector fields as p5.Vector', async function() { + const input = [ + { position: myp5.createVector(1, 2), speed: 5.0 }, + { position: myp5.createVector(3, 4), speed: 10.0 }, + ]; + const buf = myp5.createStorage(input); + + const result = await buf.read(); + + expect(result).to.be.an('array'); + expect(result.length).to.equal(2); + // Vector fields come back as p5.Vector + expect(result[0].position.isVector).to.be.true; + expect(result[0].position.x).to.be.closeTo(1, 0.001); + expect(result[0].position.y).to.be.closeTo(2, 0.001); + expect(result[0].speed).to.be.closeTo(5.0, 0.001); + expect(result[1].position.isVector).to.be.true; + expect(result[1].position.x).to.be.closeTo(3, 0.001); + expect(result[1].position.y).to.be.closeTo(4, 0.001); + expect(result[1].speed).to.be.closeTo(10.0, 0.001); + }); + + test('reads back data modified by a compute shader', async function() { + const input = new Float32Array([1, 2, 3, 4]); + const buf = myp5.createStorage(input); + + const computeShader = myp5.buildComputeShader(() => { + const d = myp5.uniformStorage(); + const idx = myp5.index.x; + d[idx] = d[idx] * 2; + }, { myp5 }); + + computeShader.setUniform('d', buf); + myp5.compute(computeShader, 4); + + const result = await buf.read(); + + expect(result).to.be.instanceOf(Float32Array); + for (let i = 0; i < input.length; i++) { + expect(result[i]).to.be.closeTo(input[i] * 2, 0.001); + } + }); + }); + + suite('StorageBuffer.set()', function() { + test('updates a single float value at the given index', async function() { + const buf = myp5.createStorage(new Float32Array([1, 2, 3, 4])); + buf.set(2, 9.5); + + const result = await buf.read(); + + expect(result[0]).to.be.closeTo(1, 0.001); + expect(result[1]).to.be.closeTo(2, 0.001); + expect(result[2]).to.be.closeTo(9.5, 0.001); // only this changed + expect(result[3]).to.be.closeTo(4, 0.001); + }); + + test('updates a single struct element without touching neighbours', async function() { + const input = [ + { x: 1.0, y: 2.0 }, + { x: 3.0, y: 4.0 }, + { x: 5.0, y: 6.0 }, + ]; + const buf = myp5.createStorage(input); + + // Replace only the middle element + buf.set(1, { x: 99.0, y: 88.0 }); + + const result = await buf.read(); + + expect(result.length).to.be.at.least(3); + // Element 0 unchanged + expect(result[0].x).to.be.closeTo(1.0, 0.001); + expect(result[0].y).to.be.closeTo(2.0, 0.001); + // Element 1 updated + expect(result[1].x).to.be.closeTo(99.0, 0.001); + expect(result[1].y).to.be.closeTo(88.0, 0.001); + // Element 2 unchanged + expect(result[2].x).to.be.closeTo(5.0, 0.001); + expect(result[2].y).to.be.closeTo(6.0, 0.001); + }); + + test('set() then read() reflects the new value immediately', async function() { + const buf = myp5.createStorage(new Float32Array([0, 0, 0])); + buf.set(0, 42); + + const result = await buf.read(); + expect(result[0]).to.be.closeTo(42, 0.001); + }); + + test('throws on out-of-bounds index for float buffer', function() { + const buf = myp5.createStorage(new Float32Array([1, 2, 3])); + expect(() => buf.set(10, 5.0)).to.throw(); + }); + + test('throws on out-of-bounds index for struct buffer', function() { + const buf = myp5.createStorage([{ x: 1.0 }, { x: 2.0 }]); + expect(() => buf.set(99, { x: 3.0 })).to.throw(); + }); + + test('throws when passing a non-number to a float buffer', function() { + const buf = myp5.createStorage(new Float32Array([1, 2, 3])); + expect(() => buf.set(0, { x: 1 })).to.throw(); + }); + + test('throws when passing a non-object to a struct buffer', function() { + const buf = myp5.createStorage([{ x: 1.0 }, { x: 2.0 }]); + expect(() => buf.set(0, 42)).to.throw(); + }); + }); + + suite('p5.strands', function() { + test('a uniform whose name matches a hook parameter name does not break', async function() { + myp5.pixelDensity(1); + + // 'color' is the WGSL parameter name of the getFinalColor hook's first argument. + // Creating a uniform with the same name used to cause a WGSL name clash. + const myShader = myp5.baseColorShader().modify(() => { + const color = myp5.uniformFloat('color', 0.5); + myp5.finalColor.begin(); + myp5.finalColor.set([color, color, color, 1]); + myp5.finalColor.end(); + }, { myp5 }); + + myp5.background(0); + myp5.noStroke(); + myp5.shader(myShader); + myp5.plane(myp5.width, myp5.height); + + const pixel = await myp5.get(5, 5); + expect(pixel[0]).to.be.closeTo(128, 1); + expect(pixel[0]).to.equal(pixel[1]); + expect(pixel[1]).to.equal(pixel[2]); + expect(pixel[3]).to.equal(255); + }); + }); }); diff --git a/test/unit/webgpu/p5.Shader.js b/test/unit/webgpu/p5.Shader.js index 7453ee9d2b..eb9bb79990 100644 --- a/test/unit/webgpu/p5.Shader.js +++ b/test/unit/webgpu/p5.Shader.js @@ -490,7 +490,6 @@ suite('WebGPU p5.Shader', function() { return [0.4, 0, 0, 1]; }); }, { myp5 }); - console.log(testShader.fragSrc()) myp5.background(255, 255, 255); myp5.filter(testShader); @@ -502,6 +501,55 @@ suite('WebGPU p5.Shader', function() { }); }); + suite('ternary expressions', () => { + test('ternary changes color based on left/right side of canvas', async () => { + await myp5.createCanvas(50, 25, myp5.WEBGPU); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + inputs.color = inputs.texCoord.x > 0.5 ? [1, 0, 0, 1] : [0, 0, 1, 1]; + return inputs; + }); + }, { myp5 }); + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const leftPixel = await myp5.get(12, 12); + assert.approximately(leftPixel[0], 0, 5); + assert.approximately(leftPixel[1], 0, 5); + assert.approximately(leftPixel[2], 255, 5); + + const rightPixel = await myp5.get(37, 12); + assert.approximately(rightPixel[0], 255, 5); + assert.approximately(rightPixel[1], 0, 5); + assert.approximately(rightPixel[2], 0, 5); + }); + + test('ternary with scalar values', async () => { + await myp5.createCanvas(50, 25, myp5.WEBGPU); + const testShader = myp5.baseMaterialShader().modify(() => { + myp5.getPixelInputs(inputs => { + const brightness = inputs.texCoord.x > 0.5 ? 1.0 : 0.0; + inputs.color = [brightness, brightness, brightness, 1]; + return inputs; + }); + }, { myp5 }); + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const leftPixel = await myp5.get(12, 12); + assert.approximately(leftPixel[0], 0, 5); + assert.approximately(leftPixel[1], 0, 5); + assert.approximately(leftPixel[2], 0, 5); + + const rightPixel = await myp5.get(37, 12); + assert.approximately(rightPixel[0], 255, 5); + assert.approximately(rightPixel[1], 255, 5); + assert.approximately(rightPixel[2], 255, 5); + }); + }); + suite('for loop statements', () => { test('handle simple for loop with known iteration count', async () => { await myp5.createCanvas(50, 50, myp5.WEBGPU); @@ -1180,5 +1228,139 @@ suite('WebGPU p5.Shader', function() { }); } }); + + suite('compute shaders', () => { + test('handle early return in void compute hook', async () => { + await myp5.createCanvas(5, 5, myp5.WEBGPU); + + // This test verifies that buildComputeShader and p5.compute + // correctly handle void hooks with early returns without crashing + // the strands compiler or hitting type errors. + expect(() => { + const computeShader = myp5.buildComputeShader(() => { + const id = myp5.index.x; + if (id > 10) { + return; // Early return in void hook + } + }, { myp5 }); + + myp5.compute(computeShader, 1); + }).not.toThrow(); + }); + + test('early return in void compute hook stops execution', async () => { + await myp5.createCanvas(5, 5, myp5.WEBGPU); + const data = myp5.createStorage([0]); + + const computeShader = myp5.buildComputeShader(() => { + const buf = myp5.uniformStorage(); + const id = myp5.index.x; + if (id == 0) { + buf[0] = 1.0; + return; + buf[0] = 2.0; // Should not execute + } + }, { myp5 }); + + computeShader.setUniform('buf', data); + + expect(() => { + myp5.compute(computeShader, 1); + }).not.toThrow(); + }); + }); + + suite('array indexing on non-storage vectors (#8756)', () => { + test('indexing into array returned from helper function does not throw', async () => { + await myp5.createCanvas(5, 5, myp5.WEBGPU); + const storage = myp5.createStorage(new Float32Array(4)); + + const computeShader = myp5.buildComputeShader(() => { + const data = myp5.uniformStorage(); + function getArray() { + return [1, 2]; + } + const arr = getArray(); + data[myp5.index.x] = arr[0] + arr[1] + myp5.index.x; + }, { myp5 }); + + computeShader.setUniform('data', storage); + + expect(() => { + myp5.compute(computeShader, 4); + }).not.toThrow(); + }); + + test('inline literal indexing [1, 2][0] works end-to-end', async () => { + await myp5.createCanvas(5, 5, myp5.WEBGPU); + const storage = myp5.createStorage(new Float32Array(4)); + + const computeShader = myp5.buildComputeShader(() => { + const data = myp5.uniformStorage(); + data[myp5.index.x] = [1, 2][0]; + }, { myp5 }); + + computeShader.setUniform('data', storage); + + expect(() => { + myp5.compute(computeShader, 4); + }).not.toThrow(); + }); + + test('array literal with 1 element throws descriptive error', async () => { + await myp5.createCanvas(5, 5, myp5.WEBGPU); + + expect(() => { + myp5.buildComputeShader(() => { + const data = myp5.uniformStorage(); + const arr = [1]; + data[myp5.index.x] = arr[0]; + }, { myp5 }); + }).toThrow('and must have 2-4 elements (got 1)'); + }); + + test('array literal with 5 elements throws descriptive error', async () => { + await myp5.createCanvas(5, 5, myp5.WEBGPU); + + expect(() => { + myp5.buildComputeShader(() => { + const data = myp5.uniformStorage(); + const arr = [1, 2, 3, 4, 5]; + data[myp5.index.x] = arr[0]; + }, { myp5 }); + }).toThrow('and must have 2-4 elements (got 5)'); + }); + + test('valid array lengths 2, 3, 4 do not throw', async () => { + await myp5.createCanvas(5, 5, myp5.WEBGPU); + const storage = myp5.createStorage(new Float32Array(4)); + + expect(() => { + const s2 = myp5.buildComputeShader(() => { + const data = myp5.uniformStorage(); + const arr = [1, 2]; + data[myp5.index.x] = arr[0]; + }, { myp5 }); + s2.setUniform('data', storage); + myp5.compute(s2, 4); + + const s3 = myp5.buildComputeShader(() => { + const data = myp5.uniformStorage(); + const arr = [1, 2, 3]; + data[myp5.index.x] = arr[0]; + }, { myp5 }); + s3.setUniform('data', storage); + myp5.compute(s3, 4); + + const s4 = myp5.buildComputeShader(() => { + const data = myp5.uniformStorage(); + const arr = [1, 2, 3, 4]; + data[myp5.index.x] = arr[0]; + }, { myp5 }); + s4.setUniform('data', storage); + myp5.compute(s4, 4); + }).not.toThrow(); + }); + }); }); }); diff --git a/utils/data-processor.mjs b/utils/data-processor.mjs index 022e188792..7349e10773 100644 --- a/utils/data-processor.mjs +++ b/utils/data-processor.mjs @@ -152,6 +152,8 @@ export function processData(rawData, strategy) { submodule, class: forEntry || 'p5', beta: entry.tags?.some(t => t.title === 'beta') || undefined, + webgpu: entry.tags?.some(t => t.title === 'webgpu') || undefined, + webgpuOnly: entry.tags?.some(t => t.title === 'webgpuOnly') || undefined, }; processed.classitems.push(item); @@ -188,7 +190,10 @@ export function processData(rawData, strategy) { }, is_constructor: 1, module, - submodule + submodule, + beta: entry.tags?.some(t => t.title === 'beta') || undefined, + webgpu: entry.tags?.some(t => t.title === 'webgpu') || undefined, + webgpuOnly: entry.tags?.some(t => t.title === 'webgpuOnly') || undefined, }; // The @private tag doesn't seem to end up in the Documentation.js output. @@ -269,6 +274,8 @@ export function processData(rawData, strategy) { module: prevItem?.module ?? module, submodule: prevItem?.submodule ?? submodule, beta: prevItem?.beta || entry.tags?.some(t => t.title === 'beta') || undefined, + webgpu: prevItem?.webgpu || entry.tags?.some(t => t.title === 'webgpu') || undefined, + webgpuOnly: prevItem?.webgpuOnly || entry.tags?.some(t => t.title === 'webgpuOnly') || undefined, }; processed.classMethods[className] = processed.classMethods[className] || {}; diff --git a/utils/patch.mjs b/utils/patch.mjs index 446a6ef755..841ac9c703 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -175,4 +175,3 @@ export function applyPatches() { } } } - diff --git a/utils/typescript.mjs b/utils/typescript.mjs index ad119cf80e..751ff117f8 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -29,7 +29,8 @@ allRawData.forEach(entry => { if (entry.kind === 'constant' || entry.kind === 'typedef') { constantsLookup.add(entry.name); if (entry.kind === 'typedef') { - typedefs[entry.name] = entry.type; + // Store the full entry so we have access to both .type and .properties + typedefs[entry.name] = entry; } } }); @@ -242,15 +243,29 @@ function convertTypeToTypeScript(typeNode, options = {}) { } } - // Check if this is a p5 constant - use typeof since they're defined as values + // Check if this is a p5 constant/typedef if (constantsLookup.has(typeName)) { + const typedefEntry = typedefs[typeName]; + + // Use interface name for object-shaped typedefs in all contexts + if (typedefEntry && hasTypedefProperties(typedefEntry)) { + if (inGlobalMode) { + return `P5.${typeName}`; + } else if (isInsideNamespace) { + return typeName; + } else { + return `p5.${typeName}`; + } + } + + // Fallback to typeof or primitive resolution for alias-style typedefs if (inGlobalMode) { return `typeof P5.${typeName}`; - } else if (typedefs[typeName]) { + } else if (typedefEntry) { if (isConstantDef) { - return convertTypeToTypeScript(typedefs[typeName], options); + return convertTypeToTypeScript(typedefEntry.type, options); } else { - return `typeof p5.${typeName}` + return `typeof p5.${typeName}`; } } else { return `Symbol`; @@ -330,6 +345,105 @@ function convertTypeToTypeScript(typeNode, options = {}) { } } +// Check if typedef represents a real object shape +function hasTypedefProperties(typedefEntry) { + if (!Array.isArray(typedefEntry.properties) || typedefEntry.properties.length === 0) { + return false; + } + // Reject self-referential single-property typedefs + if ( + typedefEntry.properties.length === 1 && + typedefEntry.properties[0].name === typedefEntry.name + ) { + return false; + } + return true; +} + +// Convert JSDoc FunctionType into a TypeScript function signature string +function convertFunctionTypeForInterface(typeNode, options) { + const params = (typeNode.params || []) + .map((param, i) => { + let typeObj; + let paramName; + if (param.type === 'ParameterType') { + typeObj = param.expression; + paramName = param.name ?? `p${i}`; + } else if (typeof param.type === 'object' && param.type !== null) { + typeObj = param.type; + paramName = param.name ?? `p${i}`; + } else { + // param itself is a plain type node + typeObj = param; + paramName = `p${i}`; + } + const paramType = convertTypeToTypeScript(typeObj, options); + return `${paramName}: ${paramType}`; + }) + .join(', '); + + const returnType = typeNode.result + ? convertTypeToTypeScript(typeNode.result, options) + : 'void'; + + // Normalise 'undefined' return to 'void' for idiomatic TypeScript + const normalisedReturn = returnType === 'undefined' ? 'void' : returnType; + + return `(${params}) => ${normalisedReturn}`; +} + +// Generate a TypeScript interface from a typedef with @property fields +function generateTypedefInterface(name, typedefEntry, options = {}, indent = 2) { + const pad = ' '.repeat(indent); + const innerPad = ' '.repeat(indent + 2); + let output = ''; + + if (typedefEntry.description) { + const descStr = typeof typedefEntry.description === 'string' + ? typedefEntry.description + : descriptionStringForTypeScript(typedefEntry.description); + if (descStr) { + output += `${pad}/**\n`; + output += formatJSDocComment(descStr, indent) + '\n'; + output += `${pad} */\n`; + } + } + + output += `${pad}interface ${name} {\n`; + + for (const prop of typedefEntry.properties) { + // Each prop: { name, type, description, optional } + const propName = prop.name; + const rawType = prop.type; + const isOptional = prop.optional || rawType?.type === 'OptionalType'; + const optMark = isOptional ? '?' : ''; + + if (prop.description) { + const propDescStr = typeof prop.description === 'string' + ? prop.description.trim() + : descriptionStringForTypeScript(prop.description); + if (propDescStr) { + output += `${innerPad}/** ${propDescStr} */\n`; + } + } + + if (rawType?.type === 'FunctionType') { + // Render FunctionType properties as method signatures instead of arrow properties + const sig = convertFunctionTypeForInterface(rawType, options); + const arrowIdx = sig.lastIndexOf('=>'); + const paramsPart = sig.substring(0, arrowIdx).trim(); + const retPart = sig.substring(arrowIdx + 2).trim(); + output += `${innerPad}${propName}${paramsPart}: ${retPart};\n`; + } else { + const tsType = rawType ? convertTypeToTypeScript(rawType, options) : 'any'; + output += `${innerPad}${propName}${optMark}: ${tsType};\n`; + } + } + + output += `${pad}}\n\n`; + return output; +} + // Strategy for TypeScript output const typescriptStrategy = { shouldSkipEntry: (entry, context) => { @@ -606,6 +720,10 @@ function generateTypeDefinitions() { if (seenConstants.has(item.name)) { return false; } + // Skip typedefs that have real object shapes + if (typedefs[item.name] && hasTypedefProperties(typedefs[item.name])) { + return false; + } seenConstants.add(item.name); return true; } @@ -667,13 +785,20 @@ function generateTypeDefinitions() { output += '\n'; - p5Constants.forEach(constant => { output += `${mutableProperties.has(constant.name) ? 'let' : 'const'} ${constant.name}: typeof __${constant.name};\n`; }); output += '\n'; + // Emit interfaces for typedefs that define object shapes + const namespaceOptions = { isInsideNamespace: true }; + for (const [name, typedefEntry] of Object.entries(typedefs)) { + if (hasTypedefProperties(typedefEntry)) { + output += generateTypedefInterface(name, typedefEntry, namespaceOptions, 2); + } + } + // Generate other classes in namespace Object.values(processed.classes).forEach(classData => { if (classData.name !== 'p5') { @@ -750,6 +875,14 @@ p5: P5; globalDefinitions += '\n'; + // Mirror typedef interfaces for global-mode usage + const globalNamespaceOptions = { isInsideNamespace: true, inGlobalMode: true }; + for (const [name, typedefEntry] of Object.entries(typedefs)) { + if (hasTypedefProperties(typedefEntry)) { + globalDefinitions += generateTypedefInterface(name, typedefEntry, globalNamespaceOptions, 2); + } + } + // Add all real classes as both types and constructors Object.values(processed.classes).forEach(classData => { if (classData.name !== 'p5') {