├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci-cd.yml │ ├── commitlint.yml │ └── signature-assistant.yml ├── .gitignore ├── .husky ├── .gitattributes └── commit-msg ├── .jsdoc.json ├── .npmignore ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TRADEMARK ├── commitlint.config.js ├── docs └── Rectangle-AABB-Matrix.md ├── package-lock.json ├── package.json ├── release.config.js ├── renovate.json5 ├── src ├── .eslintrc.js ├── BitmapSkin.js ├── Drawable.js ├── EffectTransform.js ├── PenSkin.js ├── Rectangle.js ├── RenderConstants.js ├── RenderWebGL.js ├── SVGSkin.js ├── ShaderManager.js ├── Silhouette.js ├── Skin.js ├── TextBubbleSkin.js ├── index.js ├── playground │ ├── .eslintrc.js │ ├── getMousePosition.js │ ├── index.html │ ├── playground.js │ ├── queryPlayground.html │ ├── queryPlayground.js │ └── style.css ├── shaders │ ├── sprite.frag │ └── sprite.vert └── util │ ├── canvas-measurement-provider.js │ ├── color-conversions.js │ ├── log.js │ └── text-wrapper.js ├── test ├── fixtures │ └── MockSkin.js ├── helper │ └── page-util.js ├── integration │ ├── cpu-render.html │ ├── index.html │ ├── pick-tests.js │ ├── pick-tests │ │ └── test-mouse-touch.sb2 │ ├── scratch-tests.js │ ├── scratch-tests │ │ ├── cat-touches-box.sb2 │ │ ├── cat-touches-pen.sb2 │ │ ├── clear-color.sb3 │ │ ├── color-touching-tests.sb2 │ │ ├── disappearing-pen.sb3 │ │ ├── doesnt-touch-say-bubble.sb2 │ │ ├── fencing-bounds.sb3 │ │ ├── ghost-hidden-collide.sb2 │ │ ├── pixelate-touching.sb3 │ │ ├── sprite-goes-off-stage.sb2 │ │ └── tippy-toe-collision.sb2 │ └── skin-size-tests.js └── unit │ ├── ColorConversionTests.js │ └── DrawableTests.js └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | 10 | [*.{js,jsx,html}] 11 | indent_style = space 12 | 13 | [*.{frag,vert}] 14 | indent_style = tab 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | node_modules/* 3 | playground/* 4 | tap-snapshots/* 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['scratch', 'scratch/node', 'scratch/es6'] 4 | }; 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly specify line endings for as many files as possible. 5 | # People who (for example) rsync between Windows and Linux need this. 6 | 7 | # File types which we know are binary 8 | 9 | # Prefer LF for most file types 10 | *.frag text eol=lf 11 | *.htm text eol=lf 12 | *.html text eol=lf 13 | *.iml text eol=lf 14 | *.js text eol=lf 15 | *.js.map text eol=lf 16 | *.json text eol=lf 17 | *.json5 text eol=lf 18 | *.md text eol=lf 19 | *.vert text eol=lf 20 | *.xml text eol=lf 21 | *.yml text eol=lf 22 | 23 | # Prefer LF for these files 24 | .editorconfig text eol=lf 25 | .eslintignore text eol=lf 26 | .gitattributes text eol=lf 27 | .gitignore text eol=lf 28 | .gitmodules text eol=lf 29 | LICENSE text eol=lf 30 | Makefile text eol=lf 31 | README text eol=lf 32 | TRADEMARK text eol=lf 33 | 34 | # Use CRLF for Windows-specific file types 35 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | The development of Scratch is an ongoing process, and we love to have people in the Scratch and open source communities help us along the way. 3 | 4 | ### Ways to Help 5 | 6 | * **Documenting bugs** 7 | * If you've identified a bug in Scratch you should first check to see if it's been filed as an issue, if not you can file one. Make sure you follow the issue template. 8 | * It's important that we can consistently reproduce issues. When writing an issue, be sure to follow our [reproduction step guidelines](https://github.com/LLK/scratch-gui/wiki/Writing-good-repro-steps). 9 | * Some issues are marked "Needs Repro". Adding a comment with good reproduction steps to those issues is a great way to help. 10 | * If you don't have an issue in mind already, you can look through the [Bugs & Glitches forum.](https://scratch.mit.edu/discuss/3/) Look for users reporting problems, reproduce the problem yourself, and file new issues following our guidelines. 11 | 12 | * **Fixing bugs** 13 | * You can request to fix a bug in a comment on the issue if you at mention the repo coordinator, who for this repo is @cwillisf. 14 | * If the issue is marked "Help Wanted" you can go ahead and start working on it! 15 | * **We will only accept Pull Requests for bugs that have an issue filed that has a priority label** 16 | * If you're interested in fixing a bug with no issue, file the issue first and wait for it to have a priority added to it. 17 | 18 | * We are not looking for Pull Requests ("PR") for every issue and may deny a PR if it doesn't fit our criteria. 19 | * We are far more likely to accept a PR if it is for an issue marked with Help Wanted. 20 | * We will not accept PRs for issues marked with "Needs Discussion" or "Needs Design." 21 | * Wait until the Repo Coordinator assigns the issue to you before you begin work or submit a PR. 22 | 23 | ### Learning Git and Github 24 | 25 | If you want to work on fixing issues, you should be familiar with Git and Github. 26 | 27 | * [Learn Git branching](https://learngitbranching.js.org/) includes an introduction to basic git commands and useful branching features. 28 | * Here's a general introduction to [contributing to an open source project](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 29 | 30 | **Important:** we follow the [Github Flow process](https://guides.github.com/introduction/flow/) as our development process. 31 | 32 | ### How to Fix Bugs 33 | 1. Identify which Github issue you are working on. Leave a comment on the issue to let us (and other contributors) know you're working on it. 34 | 2. Make sure you have a fork of this repo (see [Github's forking a repo](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) for details) 35 | 3. Switch to the `develop` branch, and pull down the latest changes from upstream 36 | 4. Run the code, and reproduce the problem 37 | 5. Create your branch from the `develop` branch 38 | 6. Make code changes to fix the problem 39 | 7. Run `npm test` to make sure that your changes pass our tests 40 | 8. Commit your changes 41 | 9. Push your branch to your fork 42 | 10. Create your pull request 43 | 1. Make sure to follow the template in the PR description 44 | 1. Remember to check the “[Allow edits from maintainers](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork)” box 45 | 46 | When submitting pull requests keep in mind: 47 | * please be patient -- it can take a while to find time to review them 48 | * try to change the least amount of code necessary to fix the bug 49 | * the code can't be radically changed without significant coordination with the Scratch Team, so these types of changes should be avoided 50 | * if you find yourself changing a substantial amount of code or considering radical changes, please ask for clarification -- we may have envisioned a different approach, or underestimated the amount of effort 51 | 52 | ### Suggestions 53 | ![Block sketch](https://user-images.githubusercontent.com/3431616/77192550-1dcebe00-6ab3-11ea-9606-8ecd8500c958.png) 54 | 55 | Please note: **_we are unlikely to accept PRs with new features that haven't been thought through and discussed as a group_**. 56 | 57 | Why? Because we have a strong belief in the value of keeping things simple for new users. It's been said that the Scratch Team spends about one hour of design discussion for every pixel in Scratch. To learn more about our design philosophy, see [the Scratch Developers page](https://scratch.mit.edu/developers), or [this paper](http://web.media.mit.edu/~mres/papers/Scratch-CACM-final.pdf). 58 | 59 | We welcome suggestions! If you want to suggest a feature, please post in our [suggestions forum](https://scratch.mit.edu/discuss/1/). Your suggestion will be helped if you include a mockup design; this can be simple, even hand-drawn. 60 | 61 | ### Other resources 62 | Beyond this repo, there are also some other resources that you might want to take a look at: 63 | * [Community Guidelines](https://github.com/LLK/scratch-www/wiki/Community-Guidelines) (we find it important to maintain a constructive and welcoming community, just like on Scratch) 64 | * [Open Source forum](https://scratch.mit.edu/discuss/49/) on Scratch 65 | * [Suggestions forum](https://scratch.mit.edu/discuss/1/) on Scratch 66 | * [Bugs & Glitches forum](https://scratch.mit.edu/discuss/3/) on Scratch 67 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Expected Behavior 2 | 3 | _Please describe what should happen_ 4 | 5 | ### Actual Behavior 6 | 7 | _Describe what actually happens_ 8 | 9 | ### Steps to Reproduce 10 | 11 | _Explain what someone needs to do in order to see what's described in *Actual behavior* above_ 12 | 13 | ### Operating System and Browser 14 | 15 | _e.g. Mac OS 10.11.6 Safari 10.0_ 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Resolves 2 | 3 | _What Github issue does this resolve (please include link)?_ 4 | 5 | ### Proposed Changes 6 | 7 | _Describe what this Pull Request does_ 8 | 9 | ### Reason for Changes 10 | 11 | _Explain why these changes should be made_ 12 | 13 | ### Test Coverage 14 | 15 | _Please show how you have added tests to cover your changes_ 16 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | workflow_dispatch: # Allows you to run this workflow manually from the Actions tab 5 | push: # Runs whenever a commit is pushed to the repository 6 | 7 | concurrency: 8 | group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" 9 | cancel-in-progress: true 10 | 11 | permissions: 12 | contents: write # publish a GitHub release 13 | pages: write # deploy to GitHub Pages 14 | issues: write # comment on released issues 15 | pull-requests: write # comment on released pull requests 16 | 17 | jobs: 18 | ci-cd: 19 | runs-on: ubuntu-latest 20 | env: 21 | TRIGGER_DEPLOY: ${{ startsWith(github.ref, 'refs/heads/master') || startsWith(github.ref, 'refs/heads/hotfix') || startsWith(github.ref, 'refs/heads/develop') || startsWith(github.ref, 'refs/heads/beta') }} 22 | steps: 23 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 24 | - uses: wagoid/commitlint-github-action@5ce82f5d814d4010519d15f0552aec4f17a1e1fe # v5 25 | if: github.event_name == 'pull_request' 26 | - uses: actions/setup-node@26961cf329f22f6837d5f54c3efd76b480300ace # v4 27 | with: 28 | cache: "npm" 29 | node-version-file: ".nvmrc" 30 | 31 | - name: Info 32 | run: | 33 | cat < 16 | 17 | 18 | 19 | Scratch WebGL rendering demo 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ``` 28 | 29 | ```js 30 | var canvas = document.getElementById('myStage'); 31 | var debug = document.getElementById('myDebug'); 32 | 33 | // Instantiate the renderer 34 | var renderer = new require('scratch-render')(canvas); 35 | 36 | // Connect to debug canvas 37 | renderer.setDebugCanvas(debug); 38 | 39 | // Start drawing 40 | function drawStep() { 41 | renderer.draw(); 42 | requestAnimationFrame(drawStep); 43 | } 44 | drawStep(); 45 | 46 | // Connect to worker (see "playground" example) 47 | var worker = new Worker('worker.js'); 48 | renderer.connectWorker(worker); 49 | ``` 50 | 51 | ## Standalone Build 52 | ```bash 53 | npm run build 54 | ``` 55 | 56 | ```html 57 | 58 | 62 | ``` 63 | 64 | ## Testing 65 | ```bash 66 | npm test 67 | ``` 68 | 69 | ## Donate 70 | We provide [Scratch](https://scratch.mit.edu) free of charge, and want to keep it that way! Please consider making a [donation](https://secure.donationpay.org/scratchfoundation/) to support our continued engineering, design, community, and resource development efforts. Donations of any size are appreciated. Thank you! 71 | 72 | ## Committing 73 | 74 | This project uses [semantic release](https://github.com/semantic-release/semantic-release) to ensure version bumps 75 | follow semver so that projects depending on it don't break unexpectedly. 76 | 77 | In order to automatically determine version updates, semantic release expects commit messages to follow the 78 | [conventional-changelog](https://github.com/bcoe/conventional-changelog-standard/blob/master/convention.md) 79 | specification. 80 | 81 | You can use the [commitizen CLI](https://github.com/commitizen/cz-cli) to make commits formatted in this way: 82 | 83 | ```bash 84 | npm install -g commitizen@latest cz-conventional-changelog@latest 85 | ``` 86 | 87 | Now you're ready to make commits using `git cz`. 88 | -------------------------------------------------------------------------------- /TRADEMARK: -------------------------------------------------------------------------------- 1 | The Scratch trademarks, including the Scratch name, logo, the Scratch Cat, Gobo, Pico, Nano, Tera and Giga graphics (the "Marks"), are property of the Massachusetts Institute of Technology (MIT). Marks may not be used to endorse or promote products derived from this software without specific prior written permission. 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | ignores: [message => message.startsWith('chore(release):')] 4 | }; 5 | -------------------------------------------------------------------------------- /docs/Rectangle-AABB-Matrix.md: -------------------------------------------------------------------------------- 1 | # Rectangle AABB Matrix 2 | 3 | Initialize a Rectangle to a 1 unit square centered at 0 x 0 transformed by a model matrix. 4 | 5 | ----- 6 | 7 | Every drawable is a 1 x 1 unit square that is rotated by its direction, scaled by its skin size and scale, and offset by its rotation center and position. The square representation is made up of 4 points that are transformed by the drawable properties. Often we want a shape that simplifies those 4 points into a non-rotated shape, a axis aligned bounding box. 8 | 9 | One approach is to compare the x and y components of each transformed vector and find the minimum and maximum x component and the minimum and maximum y component. 10 | 11 | We can start from this approach and determine an alternative one that prodcues the same output with less work. 12 | 13 | Starting with transforming one point, here is a 3D point, `v`, transformation by a matrix, `m`. 14 | 15 | ```js 16 | const v0 = v[0]; 17 | const v1 = v[1]; 18 | const v2 = v[2]; 19 | 20 | const d = v0 * m[(0 * 4) + 3] + v1 * m[(1 * 4) + 3] + v2 * m[(2 * 4) + 3] + m[(3 * 4) + 3]; 21 | dst[0] = (v0 * m[(0 * 4) + 0] + v1 * m[(1 * 4) + 0] + v2 * m[(2 * 4) + 0] + m[(3 * 4) + 0]) / d; 22 | dst[1] = (v0 * m[(0 * 4) + 1] + v1 * m[(1 * 4) + 1] + v2 * m[(2 * 4) + 1] + m[(3 * 4) + 1]) / d; 23 | dst[2] = (v0 * m[(0 * 4) + 2] + v1 * m[(1 * 4) + 2] + v2 * m[(2 * 4) + 2] + m[(3 * 4) + 2]) / d; 24 | ``` 25 | 26 | As this is a 2D rectangle we can cancel out the third dimension, and the determinant, 'd'. 27 | 28 | ```js 29 | const v0 = v[0]; 30 | const v1 = v[1]; 31 | 32 | dst = [ 33 | v0 * m[(0 * 4) + 0] + v1 * m[(1 * 4) + 0] + m[(3 * 4) + 0, 34 | v0 * m[(0 * 4) + 1] + v1 * m[(1 * 4) + 1] + m[(3 * 4) + 1 35 | ]; 36 | ``` 37 | 38 | Let's set the matrix points to shorter names for convenience. 39 | 40 | ```js 41 | const m00 = m[(0 * 4) + 0]; 42 | const m01 = m[(0 * 4) + 1]; 43 | const m10 = m[(1 * 4) + 0]; 44 | const m11 = m[(1 * 4) + 1]; 45 | const m30 = m[(3 * 4) + 0]; 46 | const m31 = m[(3 * 4) + 1]; 47 | ``` 48 | 49 | We need 4 points with positive and negative 0.5 values so the square has sides of length 1. 50 | 51 | ```js 52 | let p = [0.5, 0.5]; 53 | let q = [-0.5, 0.5]; 54 | let r = [-0.5, -0.5]; 55 | let s = [0.5, -0.5]; 56 | ``` 57 | 58 | Transform the points by the matrix. 59 | 60 | ```js 61 | p = [ 62 | 0.5 * m00 + 0.5 * m10 + m30, 63 | 0.5 * m01 + 0.5 * m11 + m31 64 | ]; 65 | q = [ 66 | -0.5 * m00 + -0.5 * m10 + m30, 67 | 0.5 * m01 + 0.5 * m11 + m31 68 | ]; 69 | r = [ 70 | -0.5 * m00 + -0.5 * m10 + m30, 71 | -0.5 * m01 + -0.5 * m11 + m31 72 | ]; 73 | s = [ 74 | 0.5 * m00 + 0.5 * m10 + m30, 75 | -0.5 * m01 + -0.5 * m11 + m31 76 | ]; 77 | ``` 78 | 79 | With 4 transformed points we can build the left, right, top, and bottom values for the Rectangle. Each will use the minimum or the maximum of one of the components of all points. 80 | 81 | ```js 82 | const left = Math.min(p[0], q[0], r[0], s[0]); 83 | const right = Math.max(p[0], q[0], r[0], s[0]); 84 | const top = Math.max(p[1], q[1], r[1], s[1]); 85 | const bottom = Math.min(p[1], q[1], r[1], s[1]); 86 | ``` 87 | 88 | Fill those calls with the vector expressions. 89 | 90 | ```js 91 | const left = Math.min( 92 | 0.5 * m00 + 0.5 * m10 + m30, 93 | -0.5 * m00 + 0.5 * m10 + m30, 94 | -0.5 * m00 + -0.5 * m10 + m30, 95 | 0.5 * m00 + -0.5 * m10 + m30 96 | ); 97 | const right = Math.max( 98 | 0.5 * m00 + 0.5 * m10 + m30, 99 | -0.5 * m00 + 0.5 * m10 + m30, 100 | -0.5 * m00 + -0.5 * m10 + m30, 101 | 0.5 * m00 + -0.5 * m10 + m30 102 | ); 103 | const top = Math.max( 104 | 0.5 * m01 + 0.5 * m11 + m31, 105 | -0.5 * m01 + 0.5 * m11 + m31, 106 | -0.5 * m01 + -0.5 * m11 + m31, 107 | 0.5 * m01 + -0.5 * m11 + m31 108 | ); 109 | const bottom = Math.min( 110 | 0.5 * m01 + 0.5 * m11 + m31, 111 | -0.5 * m01 + 0.5 * m11 + m31, 112 | -0.5 * m01 + -0.5 * m11 + m31, 113 | 0.5 * m01 + -0.5 * m11 + m31 114 | ); 115 | ``` 116 | 117 | Pull out the `0.5 * m??` patterns. 118 | 119 | ```js 120 | const x0 = 0.5 * m00; 121 | const x1 = 0.5 * m10; 122 | const y0 = 0.5 * m01; 123 | const y1 = 0.5 * m11; 124 | 125 | const left = Math.min(x0 + x1 + m30, -x0 + x1 + m30, -x0 + -x1 + m30, x0 + -x1 + m30); 126 | const right = Math.max(x0 + x1 + m30, -x0 + x1 + m30, -x0 + -x1 + m30, x0 + -x1 + m30); 127 | const top = Math.max(y0 + y1 + m31, -y0 + y1 + m31, -y0 + -y1 + m31, y0 + -y1 + m31); 128 | const bottom = Math.min(y0 + y1 + m31, -y0 + y1 + m31, -y0 + -y1 + m31, y0 + -y1 + m31); 129 | ``` 130 | 131 | Now each argument for the min and max calls take an expression like `(a * x0 + b * x1 + m3?)`. As each expression has the x0, x1, and m3? variables we can split the min and max calls on the addition operators. Each new call has all the coefficients of that variable. 132 | 133 | ```js 134 | const left = Math.min(x0, -x0) + Math.min(x1, -x1) + Math.min(m30, m30); 135 | const right = Math.max(x0, -x0) + Math.max(x1, -x1) + Math.max(m30, m30); 136 | const top = Math.max(y0, -y0) + Math.max(y1, -y1) + Math.max(m31, m31); 137 | const bottom = Math.min(y0, -y0) + Math.min(y1, -y1) + Math.min(m31, m31); 138 | ``` 139 | 140 | The min or max of two copies of the same value will just be that value. 141 | 142 | ```js 143 | const left = Math.min(x0, -x0) + Math.min(x1, -x1) + m30; 144 | const right = Math.max(x0, -x0) + Math.max(x1, -x1) + m30; 145 | const top = Math.max(y0, -y0) + Math.max(y1, -y1) + m31; 146 | const bottom = Math.min(y0, -y0) + Math.min(y1, -y1) + m31; 147 | ``` 148 | 149 | The max of a negative and positive variable will be the absolute value of that variable. The min of a negative and positive variable will the negated absolute value of that variable. 150 | 151 | ```js 152 | const left = -Math.abs(x0) + -Math.abs(x1) + m30; 153 | const right = Math.abs(x0) + Math.abs(x1) + m30; 154 | const top = Math.abs(y0) + Math.abs(y1) + m31; 155 | const bottom = -Math.abs(y0) + -Math.abs(y1) + m31; 156 | ``` 157 | 158 | Pulling out the negations of the absolute values, left and right as well as top and bottom are the positive or negative sum of the absolute value of the saled and rotated unit value. 159 | 160 | ```js 161 | const left = -(Math.abs(x0) + Math.abs(x1)) + m30; 162 | const right = Math.abs(x0) + Math.abs(x1) + m30; 163 | const top = Math.abs(y0) + Math.abs(y1) + m31; 164 | const bottom = -(Math.abs(y0) + Math.abs(y1)) + m31; 165 | ``` 166 | 167 | We call pull out those sums and use them twice. 168 | 169 | ```js 170 | const x = Math.abs(x0) + Math.abs(x1); 171 | const y = Math.abs(y0) + Math.abs(y1); 172 | 173 | const left = -x + m30; 174 | const right = x + m30; 175 | const top = y + m31; 176 | const bottom = -y + m31; 177 | ``` 178 | 179 | This lets us arrive at our goal. Inlining some of our variables we get this block that will initialize a Rectangle to a unit square transformed by a matrix. 180 | 181 | ```js 182 | const m30 = m[(3 * 4) + 0]; 183 | const m31 = m[(3 * 4) + 1]; 184 | 185 | const x = Math.abs(0.5 * m[(0 * 4) + 0]) + Math.abs(0.5 * m[(1 * 4) + 0]); 186 | const y = Math.abs(0.5 * m[(0 * 4) + 1]) + Math.abs(0.5 * m[(1 * 4) + 1]); 187 | 188 | const left = -x + m30; 189 | const right = x + m30; 190 | const top = y + m31; 191 | const bottom = -y + m31; 192 | ``` 193 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scratch-render", 3 | "version": "2.0.400", 4 | "description": "WebGL Renderer for Scratch 3.0", 5 | "author": "Massachusetts Institute of Technology", 6 | "license": "AGPL-3.0-only", 7 | "homepage": "https://github.com/scratchfoundation/scratch-render#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/scratchfoundation/scratch-render.git", 11 | "sha": "add6668926208f29b5a4780b2069f48043ccff07" 12 | }, 13 | "exports": { 14 | "webpack": "./src/index.js", 15 | "browser": "./dist/web/scratch-render.js", 16 | "node": "./dist/node/scratch-render.js", 17 | "default": "./src/index.js" 18 | }, 19 | "scripts": { 20 | "build": "webpack --progress", 21 | "docs": "jsdoc -c .jsdoc.json", 22 | "lint": "eslint .", 23 | "prepare": "husky install", 24 | "prepublish": "npm run build", 25 | "prepublish-watch": "npm run watch", 26 | "start": "webpack-dev-server", 27 | "tap": "tap test/unit test/integration", 28 | "test": "npm run lint && npm run docs && npm run build && npm run tap", 29 | "version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"", 30 | "watch": "webpack --progress --watch --watch-poll" 31 | }, 32 | "browserslist": [ 33 | "Chrome >= 63", 34 | "Edge >= 15", 35 | "Firefox >= 57", 36 | "Safari >= 8", 37 | "Android >= 63", 38 | "iOS >= 8" 39 | ], 40 | "dependencies": { 41 | "grapheme-breaker": "^0.3.2", 42 | "hull.js": "0.2.10", 43 | "ify-loader": "^1.0.4", 44 | "linebreak": "^0.3.0", 45 | "minilog": "^3.1.0", 46 | "raw-loader": "^0.5.1", 47 | "scratch-svg-renderer": "^3.0.0", 48 | "twgl.js": "^4.4.0" 49 | }, 50 | "peerDependencies": { 51 | "scratch-render-fonts": "^1.0.0" 52 | }, 53 | "devDependencies": { 54 | "@babel/core": "7.27.3", 55 | "@babel/eslint-parser": "7.27.1", 56 | "@babel/polyfill": "7.12.1", 57 | "@babel/preset-env": "7.27.2", 58 | "@commitlint/cli": "18.6.1", 59 | "@commitlint/config-conventional": "18.6.3", 60 | "babel-loader": "9.2.1", 61 | "copy-webpack-plugin": "4.6.0", 62 | "docdash": "0.4.0", 63 | "eslint": "8.57.1", 64 | "eslint-config-scratch": "9.0.9", 65 | "gh-pages": "1.2.0", 66 | "html-webpack-plugin": "5.6.3", 67 | "husky": "8.0.3", 68 | "jsdoc": "3.6.11", 69 | "json": "9.0.6", 70 | "playwright-chromium": "1.52.0", 71 | "scratch-render-fonts": "1.0.198", 72 | "scratch-semantic-release-config": "3.0.0", 73 | "scratch-storage": "4.0.153", 74 | "scratch-vm": "5.0.300", 75 | "scratch-webpack-configuration": "3.0.0", 76 | "semantic-release": "19.0.5", 77 | "tap": "11.1.5", 78 | "terser-webpack-plugin": "5.3.14", 79 | "travis-after-all": "1.4.5", 80 | "webpack": "5.99.9", 81 | "webpack-cli": "5.1.4", 82 | "webpack-dev-server": "5.2.1" 83 | }, 84 | "config": { 85 | "commitizen": { 86 | "path": "cz-conventional-changelog" 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'scratch-semantic-release-config', 3 | branches: [ 4 | { 5 | name: 'develop' 6 | // default channel 7 | } 8 | // TODO: add hotfix config after at least one normal release 9 | ] 10 | }; 11 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | 4 | "extends": [ 5 | "github>scratchfoundation/scratch-renovate-config:js-lib-bundled" 6 | ], 7 | 8 | "packageRules": [ 9 | // Don't bump scratch-render's version number when merging a scratch-vm update 10 | // since that will cause a never-ending cycle of dependency updates. 11 | { 12 | "description": "don't bump scratch-render version when updating scratch-vm", 13 | "automerge": true, 14 | "matchPackageNames": ["scratch-vm"], 15 | "semanticCommitType": "test" // scratch-vm is a dependency of scratch-render tests only 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['scratch', 'scratch/es6', 'scratch/node'], 4 | env: { 5 | node: false, 6 | browser: true // TODO: disable this 7 | }, 8 | globals: { 9 | Buffer: true // TODO: remove this? 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /src/BitmapSkin.js: -------------------------------------------------------------------------------- 1 | const twgl = require('twgl.js'); 2 | 3 | const Skin = require('./Skin'); 4 | 5 | class BitmapSkin extends Skin { 6 | /** 7 | * Create a new Bitmap Skin. 8 | * @extends Skin 9 | * @param {!int} id - The ID for this Skin. 10 | * @param {!RenderWebGL} renderer - The renderer which will use this skin. 11 | */ 12 | constructor (id, renderer) { 13 | super(id); 14 | 15 | /** @type {!int} */ 16 | this._costumeResolution = 1; 17 | 18 | /** @type {!RenderWebGL} */ 19 | this._renderer = renderer; 20 | 21 | /** @type {Array} */ 22 | this._textureSize = [0, 0]; 23 | } 24 | 25 | /** 26 | * Dispose of this object. Do not use it after calling this method. 27 | */ 28 | dispose () { 29 | if (this._texture) { 30 | this._renderer.gl.deleteTexture(this._texture); 31 | this._texture = null; 32 | } 33 | super.dispose(); 34 | } 35 | 36 | /** 37 | * @return {Array} the "native" size, in texels, of this skin. 38 | */ 39 | get size () { 40 | return [this._textureSize[0] / this._costumeResolution, this._textureSize[1] / this._costumeResolution]; 41 | } 42 | 43 | /** 44 | * @param {Array} scale - The scaling factors to be used. 45 | * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale. 46 | */ 47 | // eslint-disable-next-line no-unused-vars 48 | getTexture (scale) { 49 | return this._texture || super.getTexture(); 50 | } 51 | 52 | /** 53 | * Set the contents of this skin to a snapshot of the provided bitmap data. 54 | * @param {ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} bitmapData - new contents for this skin. 55 | * @param {int} [costumeResolution=1] - The resolution to use for this bitmap. 56 | * @param {Array} [rotationCenter] - Optional rotation center for the bitmap. If not supplied, it will be 57 | * calculated from the bounding box 58 | * @fires Skin.event:WasAltered 59 | */ 60 | setBitmap (bitmapData, costumeResolution, rotationCenter) { 61 | if (!bitmapData.width || !bitmapData.height) { 62 | super.setEmptyImageData(); 63 | return; 64 | } 65 | const gl = this._renderer.gl; 66 | 67 | // Preferably bitmapData is ImageData. ImageData speeds up updating 68 | // Silhouette and is better handled by more browsers in regards to 69 | // memory. 70 | let textureData = bitmapData; 71 | if (bitmapData instanceof HTMLCanvasElement) { 72 | // Given a HTMLCanvasElement get the image data to pass to webgl and 73 | // Silhouette. 74 | const context = bitmapData.getContext('2d'); 75 | textureData = context.getImageData(0, 0, bitmapData.width, bitmapData.height); 76 | } 77 | 78 | if (this._texture === null) { 79 | const textureOptions = { 80 | auto: false, 81 | wrap: gl.CLAMP_TO_EDGE 82 | }; 83 | 84 | this._texture = twgl.createTexture(gl, textureOptions); 85 | } 86 | 87 | this._setTexture(textureData); 88 | 89 | // Do these last in case any of the above throws an exception 90 | this._costumeResolution = costumeResolution || 2; 91 | this._textureSize = BitmapSkin._getBitmapSize(bitmapData); 92 | 93 | if (typeof rotationCenter === 'undefined') rotationCenter = this.calculateRotationCenter(); 94 | this._rotationCenter[0] = rotationCenter[0]; 95 | this._rotationCenter[1] = rotationCenter[1]; 96 | 97 | this.emit(Skin.Events.WasAltered); 98 | } 99 | 100 | /** 101 | * @param {ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} bitmapData - bitmap data to inspect. 102 | * @returns {Array} the width and height of the bitmap data, in pixels. 103 | * @private 104 | */ 105 | static _getBitmapSize (bitmapData) { 106 | if (bitmapData instanceof HTMLImageElement) { 107 | return [bitmapData.naturalWidth || bitmapData.width, bitmapData.naturalHeight || bitmapData.height]; 108 | } 109 | 110 | if (bitmapData instanceof HTMLVideoElement) { 111 | return [bitmapData.videoWidth || bitmapData.width, bitmapData.videoHeight || bitmapData.height]; 112 | } 113 | 114 | // ImageData or HTMLCanvasElement 115 | return [bitmapData.width, bitmapData.height]; 116 | } 117 | 118 | } 119 | 120 | module.exports = BitmapSkin; 121 | -------------------------------------------------------------------------------- /src/Drawable.js: -------------------------------------------------------------------------------- 1 | const twgl = require('twgl.js'); 2 | 3 | const Rectangle = require('./Rectangle'); 4 | const RenderConstants = require('./RenderConstants'); 5 | const ShaderManager = require('./ShaderManager'); 6 | const Skin = require('./Skin'); 7 | const EffectTransform = require('./EffectTransform'); 8 | const log = require('./util/log'); 9 | 10 | /** 11 | * An internal workspace for calculating texture locations from world vectors 12 | * this is REUSED for memory conservation reasons 13 | * @type {twgl.v3} 14 | */ 15 | const __isTouchingPosition = twgl.v3.create(); 16 | const FLOATING_POINT_ERROR_ALLOWANCE = 1e-6; 17 | 18 | /** 19 | * Convert a scratch space location into a texture space float. Uses the 20 | * internal __isTouchingPosition as a return value, so this should be copied 21 | * if you ever need to get two local positions and store both. Requires that 22 | * the drawable inverseMatrix is up to date. 23 | * 24 | * @param {Drawable} drawable The drawable to get the inverse matrix and uniforms from 25 | * @param {twgl.v3} vec [x,y] scratch space vector 26 | * @return {twgl.v3} [x,y] texture space float vector - transformed by effects and matrix 27 | */ 28 | const getLocalPosition = (drawable, vec) => { 29 | // Transfrom from world coordinates to Drawable coordinates. 30 | const localPosition = __isTouchingPosition; 31 | const v0 = vec[0]; 32 | const v1 = vec[1]; 33 | const m = drawable._inverseMatrix; 34 | // var v2 = v[2]; 35 | const d = (v0 * m[3]) + (v1 * m[7]) + m[15]; 36 | // The RenderWebGL quad flips the texture's X axis. So rendered bottom 37 | // left is 1, 0 and the top right is 0, 1. Flip the X axis so 38 | // localPosition matches that transformation. 39 | localPosition[0] = 0.5 - (((v0 * m[0]) + (v1 * m[4]) + m[12]) / d); 40 | localPosition[1] = (((v0 * m[1]) + (v1 * m[5]) + m[13]) / d) + 0.5; 41 | // Fix floating point issues near 0. Filed https://github.com/LLK/scratch-render/issues/688 that 42 | // they're happening in the first place. 43 | // TODO: Check if this can be removed after render pull 479 is merged 44 | if (Math.abs(localPosition[0]) < FLOATING_POINT_ERROR_ALLOWANCE) localPosition[0] = 0; 45 | if (Math.abs(localPosition[1]) < FLOATING_POINT_ERROR_ALLOWANCE) localPosition[1] = 0; 46 | // Apply texture effect transform if the localPosition is within the drawable's space, 47 | // and any effects are currently active. 48 | if (drawable.enabledEffects !== 0 && 49 | (localPosition[0] >= 0 && localPosition[0] < 1) && 50 | (localPosition[1] >= 0 && localPosition[1] < 1)) { 51 | 52 | EffectTransform.transformPoint(drawable, localPosition, localPosition); 53 | } 54 | return localPosition; 55 | }; 56 | 57 | class Drawable { 58 | /** 59 | * An object which can be drawn by the renderer. 60 | * @todo double-buffer all rendering state (position, skin, effects, etc.) 61 | * @param {!int} id - This Drawable's unique ID. 62 | * @constructor 63 | */ 64 | constructor (id) { 65 | /** @type {!int} */ 66 | this._id = id; 67 | 68 | /** 69 | * The uniforms to be used by the vertex and pixel shaders. 70 | * Some of these are used by other parts of the renderer as well. 71 | * @type {Object.} 72 | * @private 73 | */ 74 | this._uniforms = { 75 | /** 76 | * The model matrix, to concat with projection at draw time. 77 | * @type {module:twgl/m4.Mat4} 78 | */ 79 | u_modelMatrix: twgl.m4.identity(), 80 | 81 | /** 82 | * The color to use in the silhouette draw mode. 83 | * @type {Array} 84 | */ 85 | u_silhouetteColor: Drawable.color4fFromID(this._id) 86 | }; 87 | 88 | // Effect values are uniforms too 89 | const numEffects = ShaderManager.EFFECTS.length; 90 | for (let index = 0; index < numEffects; ++index) { 91 | const effectName = ShaderManager.EFFECTS[index]; 92 | const effectInfo = ShaderManager.EFFECT_INFO[effectName]; 93 | const converter = effectInfo.converter; 94 | this._uniforms[effectInfo.uniformName] = converter(0); 95 | } 96 | 97 | this._position = twgl.v3.create(0, 0); 98 | this._scale = twgl.v3.create(100, 100); 99 | this._direction = 90; 100 | this._transformDirty = true; 101 | this._rotationMatrix = twgl.m4.identity(); 102 | this._rotationTransformDirty = true; 103 | this._rotationAdjusted = twgl.v3.create(); 104 | this._rotationCenterDirty = true; 105 | this._skinScale = twgl.v3.create(0, 0, 0); 106 | this._skinScaleDirty = true; 107 | this._inverseMatrix = twgl.m4.identity(); 108 | this._inverseTransformDirty = true; 109 | this._visible = true; 110 | 111 | /** A bitmask identifying which effects are currently in use. 112 | * @readonly 113 | * @type {int} */ 114 | this.enabledEffects = 0; 115 | 116 | /** @todo move convex hull functionality, maybe bounds functionality overall, to Skin classes */ 117 | this._convexHullPoints = null; 118 | this._convexHullDirty = true; 119 | 120 | // The precise bounding box will be from the transformed convex hull points, 121 | // so initialize the array of transformed hull points in setConvexHullPoints. 122 | // Initializing it once per convex hull recalculation avoids unnecessary creation of twgl.v3 objects. 123 | this._transformedHullPoints = null; 124 | this._transformedHullDirty = true; 125 | 126 | this._skinWasAltered = this._skinWasAltered.bind(this); 127 | 128 | this.isTouching = this._isTouchingNever; 129 | } 130 | 131 | /** 132 | * Dispose of this Drawable. Do not use it after calling this method. 133 | */ 134 | dispose () { 135 | // Use the setter: disconnect events 136 | this.skin = null; 137 | } 138 | 139 | /** 140 | * Mark this Drawable's transform as dirty. 141 | * It will be recalculated next time it's needed. 142 | */ 143 | setTransformDirty () { 144 | this._transformDirty = true; 145 | this._inverseTransformDirty = true; 146 | this._transformedHullDirty = true; 147 | } 148 | 149 | /** 150 | * @returns {number} The ID for this Drawable. 151 | */ 152 | get id () { 153 | return this._id; 154 | } 155 | 156 | /** 157 | * @returns {Skin} the current skin for this Drawable. 158 | */ 159 | get skin () { 160 | return this._skin; 161 | } 162 | 163 | /** 164 | * @param {Skin} newSkin - A new Skin for this Drawable. 165 | */ 166 | set skin (newSkin) { 167 | if (this._skin !== newSkin) { 168 | if (this._skin) { 169 | this._skin.removeListener(Skin.Events.WasAltered, this._skinWasAltered); 170 | } 171 | this._skin = newSkin; 172 | if (this._skin) { 173 | this._skin.addListener(Skin.Events.WasAltered, this._skinWasAltered); 174 | } 175 | this._skinWasAltered(); 176 | } 177 | } 178 | 179 | /** 180 | * @returns {Array} the current scaling percentages applied to this Drawable. [100,100] is normal size. 181 | */ 182 | get scale () { 183 | return [this._scale[0], this._scale[1]]; 184 | } 185 | 186 | /** 187 | * @returns {object.} the shader uniforms to be used when rendering this Drawable. 188 | */ 189 | getUniforms () { 190 | if (this._transformDirty) { 191 | this._calculateTransform(); 192 | } 193 | return this._uniforms; 194 | } 195 | 196 | /** 197 | * @returns {boolean} whether this Drawable is visible. 198 | */ 199 | getVisible () { 200 | return this._visible; 201 | } 202 | 203 | /** 204 | * Update the position if it is different. Marks the transform as dirty. 205 | * @param {Array.} position A new position. 206 | */ 207 | updatePosition (position) { 208 | if (this._position[0] !== position[0] || 209 | this._position[1] !== position[1]) { 210 | this._position[0] = Math.round(position[0]); 211 | this._position[1] = Math.round(position[1]); 212 | this.setTransformDirty(); 213 | } 214 | } 215 | 216 | /** 217 | * Update the direction if it is different. Marks the transform as dirty. 218 | * @param {number} direction A new direction. 219 | */ 220 | updateDirection (direction) { 221 | if (this._direction !== direction) { 222 | this._direction = direction; 223 | this._rotationTransformDirty = true; 224 | this.setTransformDirty(); 225 | } 226 | } 227 | 228 | /** 229 | * Update the scale if it is different. Marks the transform as dirty. 230 | * @param {Array.} scale A new scale. 231 | */ 232 | updateScale (scale) { 233 | if (this._scale[0] !== scale[0] || 234 | this._scale[1] !== scale[1]) { 235 | this._scale[0] = scale[0]; 236 | this._scale[1] = scale[1]; 237 | this._rotationCenterDirty = true; 238 | this._skinScaleDirty = true; 239 | this.setTransformDirty(); 240 | } 241 | } 242 | 243 | /** 244 | * Update visibility if it is different. Marks the convex hull as dirty. 245 | * @param {boolean} visible A new visibility state. 246 | */ 247 | updateVisible (visible) { 248 | if (this._visible !== visible) { 249 | this._visible = visible; 250 | this.setConvexHullDirty(); 251 | } 252 | } 253 | 254 | /** 255 | * Update an effect. Marks the convex hull as dirty if the effect changes shape. 256 | * @param {string} effectName The name of the effect. 257 | * @param {number} rawValue A new effect value. 258 | */ 259 | updateEffect (effectName, rawValue) { 260 | const effectInfo = ShaderManager.EFFECT_INFO[effectName]; 261 | if (rawValue) { 262 | this.enabledEffects |= effectInfo.mask; 263 | } else { 264 | this.enabledEffects &= ~effectInfo.mask; 265 | } 266 | const converter = effectInfo.converter; 267 | this._uniforms[effectInfo.uniformName] = converter(rawValue); 268 | if (effectInfo.shapeChanges) { 269 | this.setConvexHullDirty(); 270 | } 271 | } 272 | 273 | /** 274 | * Update the position, direction, scale, or effect properties of this Drawable. 275 | * @deprecated Use specific update* methods instead. 276 | * @param {object.} properties The new property values to set. 277 | */ 278 | updateProperties (properties) { 279 | if ('position' in properties) { 280 | this.updatePosition(properties.position); 281 | } 282 | if ('direction' in properties) { 283 | this.updateDirection(properties.direction); 284 | } 285 | if ('scale' in properties) { 286 | this.updateScale(properties.scale); 287 | } 288 | if ('visible' in properties) { 289 | this.updateVisible(properties.visible); 290 | } 291 | const numEffects = ShaderManager.EFFECTS.length; 292 | for (let index = 0; index < numEffects; ++index) { 293 | const effectName = ShaderManager.EFFECTS[index]; 294 | if (effectName in properties) { 295 | this.updateEffect(effectName, properties[effectName]); 296 | } 297 | } 298 | } 299 | 300 | /** 301 | * Calculate the transform to use when rendering this Drawable. 302 | * @private 303 | */ 304 | _calculateTransform () { 305 | if (this._rotationTransformDirty) { 306 | const rotation = (270 - this._direction) * Math.PI / 180; 307 | 308 | // Calling rotationZ sets the destination matrix to a rotation 309 | // around the Z axis setting matrix components 0, 1, 4 and 5 with 310 | // cosine and sine values of the rotation. 311 | // twgl.m4.rotationZ(rotation, this._rotationMatrix); 312 | 313 | // twgl assumes the last value set to the matrix was anything. 314 | // Drawable knows, it was another rotationZ matrix, so we can skip 315 | // assigning the values that will never change. 316 | const c = Math.cos(rotation); 317 | const s = Math.sin(rotation); 318 | this._rotationMatrix[0] = c; 319 | this._rotationMatrix[1] = s; 320 | // this._rotationMatrix[2] = 0; 321 | // this._rotationMatrix[3] = 0; 322 | this._rotationMatrix[4] = -s; 323 | this._rotationMatrix[5] = c; 324 | // this._rotationMatrix[6] = 0; 325 | // this._rotationMatrix[7] = 0; 326 | // this._rotationMatrix[8] = 0; 327 | // this._rotationMatrix[9] = 0; 328 | // this._rotationMatrix[10] = 1; 329 | // this._rotationMatrix[11] = 0; 330 | // this._rotationMatrix[12] = 0; 331 | // this._rotationMatrix[13] = 0; 332 | // this._rotationMatrix[14] = 0; 333 | // this._rotationMatrix[15] = 1; 334 | 335 | this._rotationTransformDirty = false; 336 | } 337 | 338 | // Adjust rotation center relative to the skin. 339 | if (this._rotationCenterDirty && this.skin !== null) { 340 | // twgl version of the following in function work. 341 | // let rotationAdjusted = twgl.v3.subtract( 342 | // this.skin.rotationCenter, 343 | // twgl.v3.divScalar(this.skin.size, 2, this._rotationAdjusted), 344 | // this._rotationAdjusted 345 | // ); 346 | // rotationAdjusted = twgl.v3.multiply( 347 | // rotationAdjusted, this._scale, rotationAdjusted 348 | // ); 349 | // rotationAdjusted = twgl.v3.divScalar( 350 | // rotationAdjusted, 100, rotationAdjusted 351 | // ); 352 | // rotationAdjusted[1] *= -1; // Y flipped to Scratch coordinate. 353 | // rotationAdjusted[2] = 0; // Z coordinate is 0. 354 | 355 | // Locally assign rotationCenter and skinSize to keep from having 356 | // the Skin getter properties called twice while locally assigning 357 | // their components for readability. 358 | const rotationCenter = this.skin.rotationCenter; 359 | const skinSize = this.skin.size; 360 | const center0 = rotationCenter[0]; 361 | const center1 = rotationCenter[1]; 362 | const skinSize0 = skinSize[0]; 363 | const skinSize1 = skinSize[1]; 364 | const scale0 = this._scale[0]; 365 | const scale1 = this._scale[1]; 366 | 367 | const rotationAdjusted = this._rotationAdjusted; 368 | rotationAdjusted[0] = (center0 - (skinSize0 / 2)) * scale0 / 100; 369 | rotationAdjusted[1] = ((center1 - (skinSize1 / 2)) * scale1 / 100) * -1; 370 | // rotationAdjusted[2] = 0; 371 | 372 | this._rotationCenterDirty = false; 373 | } 374 | 375 | if (this._skinScaleDirty && this.skin !== null) { 376 | // twgl version of the following in function work. 377 | // const scaledSize = twgl.v3.divScalar( 378 | // twgl.v3.multiply(this.skin.size, this._scale), 379 | // 100 380 | // ); 381 | // // was NaN because the vectors have only 2 components. 382 | // scaledSize[2] = 0; 383 | 384 | // Locally assign skinSize to keep from having the Skin getter 385 | // properties called twice. 386 | const skinSize = this.skin.size; 387 | const scaledSize = this._skinScale; 388 | scaledSize[0] = skinSize[0] * this._scale[0] / 100; 389 | scaledSize[1] = skinSize[1] * this._scale[1] / 100; 390 | // scaledSize[2] = 0; 391 | 392 | this._skinScaleDirty = false; 393 | } 394 | 395 | const modelMatrix = this._uniforms.u_modelMatrix; 396 | 397 | // twgl version of the following in function work. 398 | // twgl.m4.identity(modelMatrix); 399 | // twgl.m4.translate(modelMatrix, this._position, modelMatrix); 400 | // twgl.m4.multiply(modelMatrix, this._rotationMatrix, modelMatrix); 401 | // twgl.m4.translate(modelMatrix, this._rotationAdjusted, modelMatrix); 402 | // twgl.m4.scale(modelMatrix, scaledSize, modelMatrix); 403 | 404 | // Drawable configures a 3D matrix for drawing in WebGL, but most values 405 | // will never be set because the inputs are on the X and Y position axis 406 | // and the Z rotation axis. Drawable can bring the work inside 407 | // _calculateTransform and greatly reduce the ammount of math and array 408 | // assignments needed. 409 | 410 | const scale0 = this._skinScale[0]; 411 | const scale1 = this._skinScale[1]; 412 | const rotation00 = this._rotationMatrix[0]; 413 | const rotation01 = this._rotationMatrix[1]; 414 | const rotation10 = this._rotationMatrix[4]; 415 | const rotation11 = this._rotationMatrix[5]; 416 | const adjusted0 = this._rotationAdjusted[0]; 417 | const adjusted1 = this._rotationAdjusted[1]; 418 | const position0 = this._position[0]; 419 | const position1 = this._position[1]; 420 | 421 | // Commented assignments show what the values are when the matrix was 422 | // instantiated. Those values will never change so they do not need to 423 | // be reassigned. 424 | modelMatrix[0] = scale0 * rotation00; 425 | modelMatrix[1] = scale0 * rotation01; 426 | // modelMatrix[2] = 0; 427 | // modelMatrix[3] = 0; 428 | modelMatrix[4] = scale1 * rotation10; 429 | modelMatrix[5] = scale1 * rotation11; 430 | // modelMatrix[6] = 0; 431 | // modelMatrix[7] = 0; 432 | // modelMatrix[8] = 0; 433 | // modelMatrix[9] = 0; 434 | // modelMatrix[10] = 1; 435 | // modelMatrix[11] = 0; 436 | modelMatrix[12] = (rotation00 * adjusted0) + (rotation10 * adjusted1) + position0; 437 | modelMatrix[13] = (rotation01 * adjusted0) + (rotation11 * adjusted1) + position1; 438 | // modelMatrix[14] = 0; 439 | // modelMatrix[15] = 1; 440 | 441 | this._transformDirty = false; 442 | } 443 | 444 | /** 445 | * Whether the Drawable needs convex hull points provided by the renderer. 446 | * @return {boolean} True when no convex hull known, or it's dirty. 447 | */ 448 | needsConvexHullPoints () { 449 | return !this._convexHullPoints || this._convexHullDirty || this._convexHullPoints.length === 0; 450 | } 451 | 452 | /** 453 | * Set the convex hull to be dirty. 454 | * Do this whenever the Drawable's shape has possibly changed. 455 | */ 456 | setConvexHullDirty () { 457 | this._convexHullDirty = true; 458 | } 459 | 460 | /** 461 | * Set the convex hull points for the Drawable. 462 | * @param {Array>} points Convex hull points, as [[x, y], ...] 463 | */ 464 | setConvexHullPoints (points) { 465 | this._convexHullPoints = points; 466 | this._convexHullDirty = false; 467 | 468 | // Re-create the "transformed hull points" array. 469 | // We only do this when the hull points change to avoid unnecessary allocations and GC. 470 | this._transformedHullPoints = []; 471 | for (let i = 0; i < points.length; i++) { 472 | this._transformedHullPoints.push(twgl.v3.create()); 473 | } 474 | this._transformedHullDirty = true; 475 | } 476 | 477 | /** 478 | * @function 479 | * @name isTouching 480 | * Check if the world position touches the skin. 481 | * The caller is responsible for ensuring this drawable's inverse matrix & its skin's silhouette are up-to-date. 482 | * @see updateCPURenderAttributes 483 | * @param {twgl.v3} vec World coordinate vector. 484 | * @return {boolean} True if the world position touches the skin. 485 | */ 486 | 487 | // `updateCPURenderAttributes` sets this Drawable instance's `isTouching` method 488 | // to one of the following three functions: 489 | // If this drawable has no skin, set it to `_isTouchingNever`. 490 | // Otherwise, if this drawable uses nearest-neighbor scaling at its current scale, set it to `_isTouchingNearest`. 491 | // Otherwise, set it to `_isTouchingLinear`. 492 | // This allows several checks to be moved from the `isTouching` function to `updateCPURenderAttributes`. 493 | 494 | // eslint-disable-next-line no-unused-vars 495 | _isTouchingNever (vec) { 496 | return false; 497 | } 498 | 499 | _isTouchingNearest (vec) { 500 | return this.skin.isTouchingNearest(getLocalPosition(this, vec)); 501 | } 502 | 503 | _isTouchingLinear (vec) { 504 | return this.skin.isTouchingLinear(getLocalPosition(this, vec)); 505 | } 506 | 507 | /** 508 | * Get the precise bounds for a Drawable. 509 | * This function applies the transform matrix to the known convex hull, 510 | * and then finds the minimum box along the axes. 511 | * Before calling this, ensure the renderer has updated convex hull points. 512 | * @param {?Rectangle} result optional destination for bounds calculation 513 | * @return {!Rectangle} Bounds for a tight box around the Drawable. 514 | */ 515 | getBounds (result) { 516 | if (this.needsConvexHullPoints()) { 517 | throw new Error('Needs updated convex hull points before bounds calculation.'); 518 | } 519 | if (this._transformDirty) { 520 | this._calculateTransform(); 521 | } 522 | const transformedHullPoints = this._getTransformedHullPoints(); 523 | // Search through transformed points to generate box on axes. 524 | result = result || new Rectangle(); 525 | result.initFromPointsAABB(transformedHullPoints); 526 | return result; 527 | } 528 | 529 | /** 530 | * Get the precise bounds for the upper 8px slice of the Drawable. 531 | * Used for calculating where to position a text bubble. 532 | * Before calling this, ensure the renderer has updated convex hull points. 533 | * @param {?Rectangle} result optional destination for bounds calculation 534 | * @return {!Rectangle} Bounds for a tight box around a slice of the Drawable. 535 | */ 536 | getBoundsForBubble (result) { 537 | if (this.needsConvexHullPoints()) { 538 | throw new Error('Needs updated convex hull points before bubble bounds calculation.'); 539 | } 540 | if (this._transformDirty) { 541 | this._calculateTransform(); 542 | } 543 | const slice = 8; // px, how tall the top slice to measure should be. 544 | const transformedHullPoints = this._getTransformedHullPoints(); 545 | const maxY = Math.max.apply(null, transformedHullPoints.map(p => p[1])); 546 | const filteredHullPoints = transformedHullPoints.filter(p => p[1] > maxY - slice); 547 | // Search through filtered points to generate box on axes. 548 | result = result || new Rectangle(); 549 | result.initFromPointsAABB(filteredHullPoints); 550 | return result; 551 | } 552 | 553 | /** 554 | * Get the rough axis-aligned bounding box for the Drawable. 555 | * Calculated by transforming the skin's bounds. 556 | * Note that this is less precise than the box returned by `getBounds`, 557 | * which is tightly snapped to account for a Drawable's transparent regions. 558 | * `getAABB` returns a much less accurate bounding box, but will be much 559 | * faster to calculate so may be desired for quick checks/optimizations. 560 | * @param {?Rectangle} result optional destination for bounds calculation 561 | * @return {!Rectangle} Rough axis-aligned bounding box for Drawable. 562 | */ 563 | getAABB (result) { 564 | if (this._transformDirty) { 565 | this._calculateTransform(); 566 | } 567 | const tm = this._uniforms.u_modelMatrix; 568 | result = result || new Rectangle(); 569 | result.initFromModelMatrix(tm); 570 | return result; 571 | } 572 | 573 | /** 574 | * Return the best Drawable bounds possible without performing graphics queries. 575 | * I.e., returns the tight bounding box when the convex hull points are already 576 | * known, but otherwise return the rough AABB of the Drawable. 577 | * @param {?Rectangle} result optional destination for bounds calculation 578 | * @return {!Rectangle} Bounds for the Drawable. 579 | */ 580 | getFastBounds (result) { 581 | if (!this.needsConvexHullPoints()) { 582 | return this.getBounds(result); 583 | } 584 | return this.getAABB(result); 585 | } 586 | 587 | /** 588 | * Transform all the convex hull points by the current Drawable's 589 | * transform. This allows us to skip recalculating the convex hull 590 | * for many Drawable updates, including translation, rotation, scaling. 591 | * @return {!Array.} Array of glPoints which are Array 592 | * @private 593 | */ 594 | _getTransformedHullPoints () { 595 | if (!this._transformedHullDirty) { 596 | return this._transformedHullPoints; 597 | } 598 | 599 | const projection = twgl.m4.ortho(-1, 1, -1, 1, -1, 1); 600 | const skinSize = this.skin.size; 601 | const halfXPixel = 1 / skinSize[0] / 2; 602 | const halfYPixel = 1 / skinSize[1] / 2; 603 | const tm = twgl.m4.multiply(this._uniforms.u_modelMatrix, projection); 604 | for (let i = 0; i < this._convexHullPoints.length; i++) { 605 | const point = this._convexHullPoints[i]; 606 | const dstPoint = this._transformedHullPoints[i]; 607 | 608 | dstPoint[0] = 0.5 + (-point[0] / skinSize[0]) - halfXPixel; 609 | dstPoint[1] = (point[1] / skinSize[1]) - 0.5 + halfYPixel; 610 | twgl.m4.transformPoint(tm, dstPoint, dstPoint); 611 | } 612 | 613 | this._transformedHullDirty = false; 614 | 615 | return this._transformedHullPoints; 616 | } 617 | 618 | /** 619 | * Update the transform matrix and calculate it's inverse for collision 620 | * and local texture position purposes. 621 | */ 622 | updateMatrix () { 623 | if (this._transformDirty) { 624 | this._calculateTransform(); 625 | } 626 | // Get the inverse of the model matrix or update it. 627 | if (this._inverseTransformDirty) { 628 | const inverse = this._inverseMatrix; 629 | twgl.m4.copy(this._uniforms.u_modelMatrix, inverse); 630 | // The normal matrix uses a z scaling of 0 causing model[10] to be 631 | // 0. Getting a 4x4 inverse is impossible without a scaling in x, y, 632 | // and z. 633 | inverse[10] = 1; 634 | twgl.m4.inverse(inverse, inverse); 635 | this._inverseTransformDirty = false; 636 | } 637 | } 638 | 639 | /** 640 | * Update everything necessary to render this drawable on the CPU. 641 | */ 642 | updateCPURenderAttributes () { 643 | this.updateMatrix(); 644 | // CPU rendering always occurs at the "native" size, so no need to scale up this._scale 645 | if (this.skin) { 646 | this.skin.updateSilhouette(this._scale); 647 | 648 | if (this.skin.useNearest(this._scale, this)) { 649 | this.isTouching = this._isTouchingNearest; 650 | } else { 651 | this.isTouching = this._isTouchingLinear; 652 | } 653 | } else { 654 | log.warn(`Could not find skin for drawable with id: ${this._id}`); 655 | 656 | this.isTouching = this._isTouchingNever; 657 | } 658 | } 659 | 660 | /** 661 | * Respond to an internal change in the current Skin. 662 | * @private 663 | */ 664 | _skinWasAltered () { 665 | this._rotationCenterDirty = true; 666 | this._skinScaleDirty = true; 667 | this.setConvexHullDirty(); 668 | this.setTransformDirty(); 669 | } 670 | 671 | /** 672 | * Calculate a color to represent the given ID number. At least one component of 673 | * the resulting color will be non-zero if the ID is not RenderConstants.ID_NONE. 674 | * @param {int} id The ID to convert. 675 | * @returns {Array} An array of [r,g,b,a], each component in the range [0,1]. 676 | */ 677 | static color4fFromID (id) { 678 | id -= RenderConstants.ID_NONE; 679 | const r = ((id >> 0) & 255) / 255.0; 680 | const g = ((id >> 8) & 255) / 255.0; 681 | const b = ((id >> 16) & 255) / 255.0; 682 | return [r, g, b, 1.0]; 683 | } 684 | 685 | /** 686 | * Calculate the ID number represented by the given color. If all components of 687 | * the color are zero, the result will be RenderConstants.ID_NONE; otherwise the result 688 | * will be a valid ID. 689 | * @param {int} r The red value of the color, in the range [0,255]. 690 | * @param {int} g The green value of the color, in the range [0,255]. 691 | * @param {int} b The blue value of the color, in the range [0,255]. 692 | * @returns {int} The ID represented by that color. 693 | */ 694 | static color3bToID (r, g, b) { 695 | let id; 696 | id = (r & 255) << 0; 697 | id |= (g & 255) << 8; 698 | id |= (b & 255) << 16; 699 | return id + RenderConstants.ID_NONE; 700 | } 701 | 702 | /** 703 | * Sample a color from a drawable's texture. 704 | * The caller is responsible for ensuring this drawable's inverse matrix & its skin's silhouette are up-to-date. 705 | * @see updateCPURenderAttributes 706 | * @param {twgl.v3} vec The scratch space [x,y] vector 707 | * @param {Drawable} drawable The drawable to sample the texture from 708 | * @param {Uint8ClampedArray} dst The "color4b" representation of the texture at point. 709 | * @param {number} [effectMask] A bitmask for which effects to use. Optional. 710 | * @returns {Uint8ClampedArray} The dst object filled with the color4b 711 | */ 712 | static sampleColor4b (vec, drawable, dst, effectMask) { 713 | const localPosition = getLocalPosition(drawable, vec); 714 | if (localPosition[0] < 0 || localPosition[1] < 0 || 715 | localPosition[0] > 1 || localPosition[1] > 1) { 716 | dst[0] = 0; 717 | dst[1] = 0; 718 | dst[2] = 0; 719 | dst[3] = 0; 720 | return dst; 721 | } 722 | 723 | const textColor = 724 | // commenting out to only use nearest for now 725 | // drawable.skin.useNearest(drawable._scale, drawable) ? 726 | drawable.skin._silhouette.colorAtNearest(localPosition, dst); 727 | // : drawable.skin._silhouette.colorAtLinear(localPosition, dst); 728 | 729 | if (drawable.enabledEffects === 0) return textColor; 730 | return EffectTransform.transformColor(drawable, textColor, effectMask); 731 | } 732 | } 733 | 734 | module.exports = Drawable; 735 | -------------------------------------------------------------------------------- /src/EffectTransform.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 3 | * A utility to transform a texture coordinate to another texture coordinate 4 | * representing how the shaders apply effects. 5 | */ 6 | 7 | const twgl = require('twgl.js'); 8 | 9 | const {rgbToHsv, hsvToRgb} = require('./util/color-conversions'); 10 | const ShaderManager = require('./ShaderManager'); 11 | 12 | /** 13 | * A texture coordinate is between 0 and 1. 0.5 is the center position. 14 | * @const {number} 15 | */ 16 | const CENTER_X = 0.5; 17 | 18 | /** 19 | * A texture coordinate is between 0 and 1. 0.5 is the center position. 20 | * @const {number} 21 | */ 22 | const CENTER_Y = 0.5; 23 | 24 | /** 25 | * Reused memory location for storing an HSV color value. 26 | * @type {Array} 27 | */ 28 | const __hsv = [0, 0, 0]; 29 | 30 | class EffectTransform { 31 | 32 | /** 33 | * Transform a color in-place given the drawable's effect uniforms. Will apply 34 | * Ghost and Color and Brightness effects. 35 | * @param {Drawable} drawable The drawable to get uniforms from. 36 | * @param {Uint8ClampedArray} inOutColor The color to transform. 37 | * @param {number} [effectMask] A bitmask for which effects to use. Optional. 38 | * @returns {Uint8ClampedArray} dst filled with the transformed color 39 | */ 40 | static transformColor (drawable, inOutColor, effectMask) { 41 | // If the color is fully transparent, don't bother attempting any transformations. 42 | if (inOutColor[3] === 0) { 43 | return inOutColor; 44 | } 45 | 46 | let effects = drawable.enabledEffects; 47 | if (typeof effectMask === 'number') effects &= effectMask; 48 | const uniforms = drawable.getUniforms(); 49 | 50 | const enableColor = (effects & ShaderManager.EFFECT_INFO.color.mask) !== 0; 51 | const enableBrightness = (effects & ShaderManager.EFFECT_INFO.brightness.mask) !== 0; 52 | 53 | if (enableColor || enableBrightness) { 54 | // gl_FragColor.rgb /= gl_FragColor.a + epsilon; 55 | // Here, we're dividing by the (previously pre-multiplied) alpha to ensure HSV is properly calculated 56 | // for partially transparent pixels. 57 | // epsilon is present in the shader because dividing by 0 (fully transparent pixels) messes up calculations. 58 | // We're doing this with a Uint8ClampedArray here, so dividing by 0 just gives 255. We're later multiplying 59 | // by 0 again, so it won't affect results. 60 | const alpha = inOutColor[3] / 255; 61 | inOutColor[0] /= alpha; 62 | inOutColor[1] /= alpha; 63 | inOutColor[2] /= alpha; 64 | 65 | if (enableColor) { 66 | // vec3 hsv = convertRGB2HSV(gl_FragColor.xyz); 67 | const hsv = rgbToHsv(inOutColor, __hsv); 68 | 69 | // this code forces grayscale values to be slightly saturated 70 | // so that some slight change of hue will be visible 71 | // const float minLightness = 0.11 / 2.0; 72 | const minV = 0.11 / 2.0; 73 | // const float minSaturation = 0.09; 74 | const minS = 0.09; 75 | // if (hsv.z < minLightness) hsv = vec3(0.0, 1.0, minLightness); 76 | if (hsv[2] < minV) { 77 | hsv[0] = 0; 78 | hsv[1] = 1; 79 | hsv[2] = minV; 80 | // else if (hsv.y < minSaturation) hsv = vec3(0.0, minSaturation, hsv.z); 81 | } else if (hsv[1] < minS) { 82 | hsv[0] = 0; 83 | hsv[1] = minS; 84 | } 85 | 86 | // hsv.x = mod(hsv.x + u_color, 1.0); 87 | // if (hsv.x < 0.0) hsv.x += 1.0; 88 | hsv[0] = (uniforms.u_color + hsv[0] + 1); 89 | 90 | // gl_FragColor.rgb = convertHSV2RGB(hsl); 91 | hsvToRgb(hsv, inOutColor); 92 | } 93 | 94 | if (enableBrightness) { 95 | const brightness = uniforms.u_brightness * 255; 96 | // gl_FragColor.rgb = clamp(gl_FragColor.rgb + vec3(u_brightness), vec3(0), vec3(1)); 97 | // We don't need to clamp because the Uint8ClampedArray does that for us 98 | inOutColor[0] += brightness; 99 | inOutColor[1] += brightness; 100 | inOutColor[2] += brightness; 101 | } 102 | 103 | // gl_FragColor.rgb *= gl_FragColor.a + epsilon; 104 | // Now we're doing the reverse, premultiplying by the alpha once again. 105 | inOutColor[0] *= alpha; 106 | inOutColor[1] *= alpha; 107 | inOutColor[2] *= alpha; 108 | } 109 | 110 | if ((effects & ShaderManager.EFFECT_INFO.ghost.mask) !== 0) { 111 | // gl_FragColor *= u_ghost 112 | inOutColor[0] *= uniforms.u_ghost; 113 | inOutColor[1] *= uniforms.u_ghost; 114 | inOutColor[2] *= uniforms.u_ghost; 115 | inOutColor[3] *= uniforms.u_ghost; 116 | } 117 | 118 | return inOutColor; 119 | } 120 | 121 | /** 122 | * Transform a texture coordinate to one that would be select after applying shader effects. 123 | * @param {Drawable} drawable The drawable whose effects to emulate. 124 | * @param {twgl.v3} vec The texture coordinate to transform. 125 | * @param {twgl.v3} dst A place to store the output coordinate. 126 | * @return {twgl.v3} dst - The coordinate after being transform by effects. 127 | */ 128 | static transformPoint (drawable, vec, dst) { 129 | twgl.v3.copy(vec, dst); 130 | 131 | const effects = drawable.enabledEffects; 132 | const uniforms = drawable.getUniforms(); 133 | if ((effects & ShaderManager.EFFECT_INFO.mosaic.mask) !== 0) { 134 | // texcoord0 = fract(u_mosaic * texcoord0); 135 | dst[0] = uniforms.u_mosaic * dst[0] % 1; 136 | dst[1] = uniforms.u_mosaic * dst[1] % 1; 137 | } 138 | if ((effects & ShaderManager.EFFECT_INFO.pixelate.mask) !== 0) { 139 | const skinUniforms = drawable.skin.getUniforms(); 140 | // vec2 pixelTexelSize = u_skinSize / u_pixelate; 141 | const texelX = skinUniforms.u_skinSize[0] / uniforms.u_pixelate; 142 | const texelY = skinUniforms.u_skinSize[1] / uniforms.u_pixelate; 143 | // texcoord0 = (floor(texcoord0 * pixelTexelSize) + kCenter) / 144 | // pixelTexelSize; 145 | dst[0] = (Math.floor(dst[0] * texelX) + CENTER_X) / texelX; 146 | dst[1] = (Math.floor(dst[1] * texelY) + CENTER_Y) / texelY; 147 | } 148 | if ((effects & ShaderManager.EFFECT_INFO.whirl.mask) !== 0) { 149 | // const float kRadius = 0.5; 150 | const RADIUS = 0.5; 151 | // vec2 offset = texcoord0 - kCenter; 152 | const offsetX = dst[0] - CENTER_X; 153 | const offsetY = dst[1] - CENTER_Y; 154 | // float offsetMagnitude = length(offset); 155 | const offsetMagnitude = Math.sqrt(Math.pow(offsetX, 2) + Math.pow(offsetY, 2)); 156 | // float whirlFactor = max(1.0 - (offsetMagnitude / kRadius), 0.0); 157 | const whirlFactor = Math.max(1.0 - (offsetMagnitude / RADIUS), 0.0); 158 | // float whirlActual = u_whirl * whirlFactor * whirlFactor; 159 | const whirlActual = uniforms.u_whirl * whirlFactor * whirlFactor; 160 | // float sinWhirl = sin(whirlActual); 161 | const sinWhirl = Math.sin(whirlActual); 162 | // float cosWhirl = cos(whirlActual); 163 | const cosWhirl = Math.cos(whirlActual); 164 | // mat2 rotationMatrix = mat2( 165 | // cosWhirl, -sinWhirl, 166 | // sinWhirl, cosWhirl 167 | // ); 168 | const rot1 = cosWhirl; 169 | const rot2 = -sinWhirl; 170 | const rot3 = sinWhirl; 171 | const rot4 = cosWhirl; 172 | 173 | // texcoord0 = rotationMatrix * offset + kCenter; 174 | dst[0] = (rot1 * offsetX) + (rot3 * offsetY) + CENTER_X; 175 | dst[1] = (rot2 * offsetX) + (rot4 * offsetY) + CENTER_Y; 176 | } 177 | if ((effects & ShaderManager.EFFECT_INFO.fisheye.mask) !== 0) { 178 | // vec2 vec = (texcoord0 - kCenter) / kCenter; 179 | const vX = (dst[0] - CENTER_X) / CENTER_X; 180 | const vY = (dst[1] - CENTER_Y) / CENTER_Y; 181 | // float vecLength = length(vec); 182 | const vLength = Math.sqrt((vX * vX) + (vY * vY)); 183 | // float r = pow(min(vecLength, 1.0), u_fisheye) * max(1.0, vecLength); 184 | const r = Math.pow(Math.min(vLength, 1), uniforms.u_fisheye) * Math.max(1, vLength); 185 | // vec2 unit = vec / vecLength; 186 | const unitX = vX / vLength; 187 | const unitY = vY / vLength; 188 | // texcoord0 = kCenter + r * unit * kCenter; 189 | dst[0] = CENTER_X + (r * unitX * CENTER_X); 190 | dst[1] = CENTER_Y + (r * unitY * CENTER_Y); 191 | } 192 | 193 | return dst; 194 | } 195 | } 196 | 197 | module.exports = EffectTransform; 198 | -------------------------------------------------------------------------------- /src/PenSkin.js: -------------------------------------------------------------------------------- 1 | const twgl = require('twgl.js'); 2 | 3 | const RenderConstants = require('./RenderConstants'); 4 | const Skin = require('./Skin'); 5 | 6 | const ShaderManager = require('./ShaderManager'); 7 | 8 | /** 9 | * Attributes to use when drawing with the pen 10 | * @typedef {object} PenSkin#PenAttributes 11 | * @property {number} [diameter] - The size (diameter) of the pen. 12 | * @property {Array} [color4f] - The pen color as an array of [r,g,b,a], each component in the range [0,1]. 13 | */ 14 | 15 | /** 16 | * The pen attributes to use when unspecified. 17 | * @type {PenSkin#PenAttributes} 18 | * @memberof PenSkin 19 | * @private 20 | * @const 21 | */ 22 | const DefaultPenAttributes = { 23 | color4f: [0, 0, 1, 1], 24 | diameter: 1 25 | }; 26 | 27 | /** 28 | * Reused memory location for storing a premultiplied pen color. 29 | * @type {FloatArray} 30 | */ 31 | const __premultipliedColor = [0, 0, 0, 0]; 32 | 33 | class PenSkin extends Skin { 34 | /** 35 | * Create a Skin which implements a Scratch pen layer. 36 | * @param {int} id - The unique ID for this Skin. 37 | * @param {RenderWebGL} renderer - The renderer which will use this Skin. 38 | * @extends Skin 39 | * @listens RenderWebGL#event:NativeSizeChanged 40 | */ 41 | constructor (id, renderer) { 42 | super(id); 43 | 44 | /** 45 | * @private 46 | * @type {RenderWebGL} 47 | */ 48 | this._renderer = renderer; 49 | 50 | /** @type {Array} */ 51 | this._size = null; 52 | 53 | /** @type {WebGLFramebuffer} */ 54 | this._framebuffer = null; 55 | 56 | /** @type {boolean} */ 57 | this._silhouetteDirty = false; 58 | 59 | /** @type {Uint8Array} */ 60 | this._silhouettePixels = null; 61 | 62 | /** @type {ImageData} */ 63 | this._silhouetteImageData = null; 64 | 65 | /** @type {object} */ 66 | this._lineOnBufferDrawRegionId = { 67 | enter: () => this._enterDrawLineOnBuffer(), 68 | exit: () => this._exitDrawLineOnBuffer() 69 | }; 70 | 71 | /** @type {object} */ 72 | this._usePenBufferDrawRegionId = { 73 | enter: () => this._enterUsePenBuffer(), 74 | exit: () => this._exitUsePenBuffer() 75 | }; 76 | 77 | /** @type {twgl.BufferInfo} */ 78 | this._lineBufferInfo = twgl.createBufferInfoFromArrays(this._renderer.gl, { 79 | a_position: { 80 | numComponents: 2, 81 | data: [ 82 | 1, 0, 83 | 0, 0, 84 | 1, 1, 85 | 1, 1, 86 | 0, 0, 87 | 0, 1 88 | ] 89 | } 90 | }); 91 | 92 | const NO_EFFECTS = 0; 93 | /** @type {twgl.ProgramInfo} */ 94 | this._lineShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.line, NO_EFFECTS); 95 | 96 | this.onNativeSizeChanged = this.onNativeSizeChanged.bind(this); 97 | this._renderer.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged); 98 | 99 | this._setCanvasSize(renderer.getNativeSize()); 100 | } 101 | 102 | /** 103 | * Dispose of this object. Do not use it after calling this method. 104 | */ 105 | dispose () { 106 | this._renderer.removeListener(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged); 107 | this._renderer.gl.deleteTexture(this._texture); 108 | this._texture = null; 109 | super.dispose(); 110 | } 111 | 112 | /** 113 | * @return {Array} the "native" size, in texels, of this skin. [width, height] 114 | */ 115 | get size () { 116 | return this._size; 117 | } 118 | 119 | useNearest (scale) { 120 | // Use nearest-neighbor interpolation when scaling up the pen skin-- this matches Scratch 2.0. 121 | // When scaling it down, use linear interpolation to avoid giving pen lines a "dashed" appearance. 122 | return Math.max(scale[0], scale[1]) >= 100; 123 | } 124 | 125 | /** 126 | * @param {Array} scale The X and Y scaling factors to be used, as percentages of this skin's "native" size. 127 | * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given size. 128 | */ 129 | // eslint-disable-next-line no-unused-vars 130 | getTexture (scale) { 131 | return this._texture; 132 | } 133 | 134 | /** 135 | * Clear the pen layer. 136 | */ 137 | clear () { 138 | this._renderer.enterDrawRegion(this._usePenBufferDrawRegionId); 139 | 140 | /* Reset framebuffer to transparent black */ 141 | const gl = this._renderer.gl; 142 | gl.clearColor(0, 0, 0, 0); 143 | gl.clear(gl.COLOR_BUFFER_BIT); 144 | 145 | this._silhouetteDirty = true; 146 | } 147 | 148 | /** 149 | * Draw a point on the pen layer. 150 | * @param {PenAttributes} penAttributes - how the point should be drawn. 151 | * @param {number} x - the X coordinate of the point to draw. 152 | * @param {number} y - the Y coordinate of the point to draw. 153 | */ 154 | drawPoint (penAttributes, x, y) { 155 | this.drawLine(penAttributes, x, y, x, y); 156 | } 157 | 158 | /** 159 | * Draw a line on the pen layer. 160 | * @param {PenAttributes} penAttributes - how the line should be drawn. 161 | * @param {number} x0 - the X coordinate of the beginning of the line. 162 | * @param {number} y0 - the Y coordinate of the beginning of the line. 163 | * @param {number} x1 - the X coordinate of the end of the line. 164 | * @param {number} y1 - the Y coordinate of the end of the line. 165 | */ 166 | drawLine (penAttributes, x0, y0, x1, y1) { 167 | // For compatibility with Scratch 2.0, offset pen lines of width 1 and 3 so they're pixel-aligned. 168 | // See https://github.com/LLK/scratch-render/pull/314 169 | const diameter = penAttributes.diameter || DefaultPenAttributes.diameter; 170 | const offset = (diameter === 1 || diameter === 3) ? 0.5 : 0; 171 | 172 | this._drawLineOnBuffer( 173 | penAttributes, 174 | x0 + offset, y0 + offset, 175 | x1 + offset, y1 + offset 176 | ); 177 | 178 | this._silhouetteDirty = true; 179 | } 180 | 181 | /** 182 | * Prepare to draw lines in the _lineOnBufferDrawRegionId region. 183 | */ 184 | _enterDrawLineOnBuffer () { 185 | const gl = this._renderer.gl; 186 | 187 | twgl.bindFramebufferInfo(gl, this._framebuffer); 188 | 189 | gl.viewport(0, 0, this._size[0], this._size[1]); 190 | 191 | const currentShader = this._lineShader; 192 | gl.useProgram(currentShader.program); 193 | twgl.setBuffersAndAttributes(gl, currentShader, this._lineBufferInfo); 194 | 195 | const uniforms = { 196 | u_skin: this._texture, 197 | u_stageSize: this._size 198 | }; 199 | 200 | twgl.setUniforms(currentShader, uniforms); 201 | } 202 | 203 | /** 204 | * Return to a base state from _lineOnBufferDrawRegionId. 205 | */ 206 | _exitDrawLineOnBuffer () { 207 | const gl = this._renderer.gl; 208 | 209 | twgl.bindFramebufferInfo(gl, null); 210 | } 211 | 212 | /** 213 | * Prepare to do things with this PenSkin's framebuffer 214 | */ 215 | _enterUsePenBuffer () { 216 | twgl.bindFramebufferInfo(this._renderer.gl, this._framebuffer); 217 | } 218 | 219 | /** 220 | * Return to a base state 221 | */ 222 | _exitUsePenBuffer () { 223 | twgl.bindFramebufferInfo(this._renderer.gl, null); 224 | } 225 | 226 | /** 227 | * Draw a line on the framebuffer. 228 | * Note that the point coordinates are in the following coordinate space: 229 | * +y is down, (0, 0) is the center, and the coords range from (-width / 2, -height / 2) to (height / 2, width / 2). 230 | * @param {PenAttributes} penAttributes - how the line should be drawn. 231 | * @param {number} x0 - the X coordinate of the beginning of the line. 232 | * @param {number} y0 - the Y coordinate of the beginning of the line. 233 | * @param {number} x1 - the X coordinate of the end of the line. 234 | * @param {number} y1 - the Y coordinate of the end of the line. 235 | */ 236 | _drawLineOnBuffer (penAttributes, x0, y0, x1, y1) { 237 | const gl = this._renderer.gl; 238 | 239 | const currentShader = this._lineShader; 240 | 241 | this._renderer.enterDrawRegion(this._lineOnBufferDrawRegionId); 242 | 243 | // Premultiply pen color by pen transparency 244 | const penColor = penAttributes.color4f || DefaultPenAttributes.color4f; 245 | __premultipliedColor[0] = penColor[0] * penColor[3]; 246 | __premultipliedColor[1] = penColor[1] * penColor[3]; 247 | __premultipliedColor[2] = penColor[2] * penColor[3]; 248 | __premultipliedColor[3] = penColor[3]; 249 | 250 | // Fun fact: Doing this calculation in the shader has the potential to overflow the floating-point range. 251 | // 'mediump' precision is only required to have a range up to 2^14 (16384), so any lines longer than 2^7 (128) 252 | // can overflow that, because you're squaring the operands, and they could end up as "infinity". 253 | // Even GLSL's `length` function won't save us here: 254 | // https://asawicki.info/news_1596_watch_out_for_reduced_precision_normalizelength_in_opengl_es 255 | const lineDiffX = x1 - x0; 256 | const lineDiffY = y1 - y0; 257 | const lineLength = Math.sqrt((lineDiffX * lineDiffX) + (lineDiffY * lineDiffY)); 258 | 259 | const uniforms = { 260 | u_lineColor: __premultipliedColor, 261 | u_lineThickness: penAttributes.diameter || DefaultPenAttributes.diameter, 262 | u_lineLength: lineLength, 263 | u_penPoints: [x0, -y0, lineDiffX, -lineDiffY] 264 | }; 265 | 266 | twgl.setUniforms(currentShader, uniforms); 267 | 268 | twgl.drawBufferInfo(gl, this._lineBufferInfo, gl.TRIANGLES); 269 | 270 | this._silhouetteDirty = true; 271 | } 272 | 273 | /** 274 | * React to a change in the renderer's native size. 275 | * @param {object} event - The change event. 276 | */ 277 | onNativeSizeChanged (event) { 278 | this._setCanvasSize(event.newSize); 279 | } 280 | 281 | /** 282 | * Set the size of the pen canvas. 283 | * @param {Array} canvasSize - the new width and height for the canvas. 284 | * @private 285 | */ 286 | _setCanvasSize (canvasSize) { 287 | const [width, height] = canvasSize; 288 | 289 | this._size = canvasSize; 290 | this._rotationCenter[0] = width / 2; 291 | this._rotationCenter[1] = height / 2; 292 | 293 | const gl = this._renderer.gl; 294 | 295 | this._texture = twgl.createTexture( 296 | gl, 297 | { 298 | mag: gl.NEAREST, 299 | min: gl.NEAREST, 300 | wrap: gl.CLAMP_TO_EDGE, 301 | width, 302 | height 303 | } 304 | ); 305 | 306 | const attachments = [ 307 | { 308 | format: gl.RGBA, 309 | attachment: this._texture 310 | } 311 | ]; 312 | if (this._framebuffer) { 313 | twgl.resizeFramebufferInfo(gl, this._framebuffer, attachments, width, height); 314 | } else { 315 | this._framebuffer = twgl.createFramebufferInfo(gl, attachments, width, height); 316 | } 317 | 318 | gl.clearColor(0, 0, 0, 0); 319 | gl.clear(gl.COLOR_BUFFER_BIT); 320 | 321 | this._silhouettePixels = new Uint8Array(Math.floor(width * height * 4)); 322 | this._silhouetteImageData = new ImageData(width, height); 323 | 324 | this._silhouetteDirty = true; 325 | } 326 | 327 | /** 328 | * If there have been pen operations that have dirtied the canvas, update 329 | * now before someone wants to use our silhouette. 330 | */ 331 | updateSilhouette () { 332 | if (this._silhouetteDirty) { 333 | this._renderer.enterDrawRegion(this._usePenBufferDrawRegionId); 334 | // Sample the framebuffer's pixels into the silhouette instance 335 | const gl = this._renderer.gl; 336 | gl.readPixels( 337 | 0, 0, 338 | this._size[0], this._size[1], 339 | gl.RGBA, gl.UNSIGNED_BYTE, this._silhouettePixels 340 | ); 341 | 342 | this._silhouetteImageData.data.set(this._silhouettePixels); 343 | this._silhouette.update(this._silhouetteImageData, true /* isPremultiplied */); 344 | 345 | this._silhouetteDirty = false; 346 | } 347 | } 348 | } 349 | 350 | module.exports = PenSkin; 351 | -------------------------------------------------------------------------------- /src/Rectangle.js: -------------------------------------------------------------------------------- 1 | class Rectangle { 2 | /** 3 | * A utility for creating and comparing axis-aligned rectangles. 4 | * Rectangles are always initialized to the "largest possible rectangle"; 5 | * use one of the init* methods below to set up a particular rectangle. 6 | * @constructor 7 | */ 8 | constructor () { 9 | this.left = -Infinity; 10 | this.right = Infinity; 11 | this.bottom = -Infinity; 12 | this.top = Infinity; 13 | } 14 | 15 | /** 16 | * Initialize a Rectangle from given Scratch-coordinate bounds. 17 | * @param {number} left Left bound of the rectangle. 18 | * @param {number} right Right bound of the rectangle. 19 | * @param {number} bottom Bottom bound of the rectangle. 20 | * @param {number} top Top bound of the rectangle. 21 | */ 22 | initFromBounds (left, right, bottom, top) { 23 | this.left = left; 24 | this.right = right; 25 | this.bottom = bottom; 26 | this.top = top; 27 | } 28 | 29 | /** 30 | * Initialize a Rectangle to the minimum AABB around a set of points. 31 | * @param {Array>} points Array of [x, y] points. 32 | */ 33 | initFromPointsAABB (points) { 34 | this.left = Infinity; 35 | this.right = -Infinity; 36 | this.top = -Infinity; 37 | this.bottom = Infinity; 38 | 39 | for (let i = 0; i < points.length; i++) { 40 | const x = points[i][0]; 41 | const y = points[i][1]; 42 | if (x < this.left) { 43 | this.left = x; 44 | } 45 | if (x > this.right) { 46 | this.right = x; 47 | } 48 | if (y > this.top) { 49 | this.top = y; 50 | } 51 | if (y < this.bottom) { 52 | this.bottom = y; 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * Initialize a Rectangle to a 1 unit square centered at 0 x 0 transformed 59 | * by a model matrix. 60 | * @param {Array.} m A 4x4 matrix to transform the rectangle by. 61 | * @tutorial Rectangle-AABB-Matrix 62 | */ 63 | initFromModelMatrix (m) { 64 | // In 2D space, we will soon use the 2x2 "top left" scale and rotation 65 | // submatrix, while we store and the 1x2 "top right" that position 66 | // vector. 67 | const m30 = m[(3 * 4) + 0]; 68 | const m31 = m[(3 * 4) + 1]; 69 | 70 | // "Transform" a (0.5, 0.5) vector by the scale and rotation matrix but 71 | // sum the absolute of each component instead of use the signed values. 72 | const x = Math.abs(0.5 * m[(0 * 4) + 0]) + Math.abs(0.5 * m[(1 * 4) + 0]); 73 | const y = Math.abs(0.5 * m[(0 * 4) + 1]) + Math.abs(0.5 * m[(1 * 4) + 1]); 74 | 75 | // And adding them to the position components initializes our Rectangle. 76 | this.left = -x + m30; 77 | this.right = x + m30; 78 | this.top = y + m31; 79 | this.bottom = -y + m31; 80 | } 81 | 82 | /** 83 | * Determine if this Rectangle intersects some other. 84 | * Note that this is a comparison assuming the Rectangle was 85 | * initialized with Scratch-space bounds or points. 86 | * @param {!Rectangle} other Rectangle to check if intersecting. 87 | * @return {boolean} True if this Rectangle intersects other. 88 | */ 89 | intersects (other) { 90 | return ( 91 | this.left <= other.right && 92 | other.left <= this.right && 93 | this.top >= other.bottom && 94 | other.top >= this.bottom 95 | ); 96 | } 97 | 98 | /** 99 | * Determine if this Rectangle fully contains some other. 100 | * Note that this is a comparison assuming the Rectangle was 101 | * initialized with Scratch-space bounds or points. 102 | * @param {!Rectangle} other Rectangle to check if fully contained. 103 | * @return {boolean} True if this Rectangle fully contains other. 104 | */ 105 | contains (other) { 106 | return ( 107 | other.left > this.left && 108 | other.right < this.right && 109 | other.top < this.top && 110 | other.bottom > this.bottom 111 | ); 112 | } 113 | 114 | /** 115 | * Clamp a Rectangle to bounds. 116 | * @param {number} left Left clamp. 117 | * @param {number} right Right clamp. 118 | * @param {number} bottom Bottom clamp. 119 | * @param {number} top Top clamp. 120 | */ 121 | clamp (left, right, bottom, top) { 122 | this.left = Math.max(this.left, left); 123 | this.right = Math.min(this.right, right); 124 | this.bottom = Math.max(this.bottom, bottom); 125 | this.top = Math.min(this.top, top); 126 | 127 | this.left = Math.min(this.left, right); 128 | this.right = Math.max(this.right, left); 129 | this.bottom = Math.min(this.bottom, top); 130 | this.top = Math.max(this.top, bottom); 131 | } 132 | 133 | /** 134 | * Push out the Rectangle to integer bounds. 135 | */ 136 | snapToInt () { 137 | this.left = Math.floor(this.left); 138 | this.right = Math.ceil(this.right); 139 | this.bottom = Math.floor(this.bottom); 140 | this.top = Math.ceil(this.top); 141 | } 142 | 143 | /** 144 | * Compute the intersection of two bounding Rectangles. 145 | * Could be an impossible box if they don't intersect. 146 | * @param {Rectangle} a One rectangle 147 | * @param {Rectangle} b Other rectangle 148 | * @param {?Rectangle} result A resulting storage rectangle (safe to pass 149 | * a or b if you want to overwrite one) 150 | * @returns {Rectangle} resulting rectangle 151 | */ 152 | static intersect (a, b, result = new Rectangle()) { 153 | result.left = Math.max(a.left, b.left); 154 | result.right = Math.min(a.right, b.right); 155 | result.top = Math.min(a.top, b.top); 156 | result.bottom = Math.max(a.bottom, b.bottom); 157 | 158 | return result; 159 | } 160 | 161 | /** 162 | * Compute the union of two bounding Rectangles. 163 | * @param {Rectangle} a One rectangle 164 | * @param {Rectangle} b Other rectangle 165 | * @param {?Rectangle} result A resulting storage rectangle (safe to pass 166 | * a or b if you want to overwrite one) 167 | * @returns {Rectangle} resulting rectangle 168 | */ 169 | static union (a, b, result = new Rectangle()) { 170 | result.left = Math.min(a.left, b.left); 171 | result.right = Math.max(a.right, b.right); 172 | // Scratch Space - +y is up 173 | result.top = Math.max(a.top, b.top); 174 | result.bottom = Math.min(a.bottom, b.bottom); 175 | return result; 176 | } 177 | 178 | /** 179 | * Width of the Rectangle. 180 | * @return {number} Width of rectangle. 181 | */ 182 | get width () { 183 | return Math.abs(this.left - this.right); 184 | } 185 | 186 | /** 187 | * Height of the Rectangle. 188 | * @return {number} Height of rectangle. 189 | */ 190 | get height () { 191 | return Math.abs(this.top - this.bottom); 192 | } 193 | 194 | } 195 | 196 | module.exports = Rectangle; 197 | -------------------------------------------------------------------------------- /src/RenderConstants.js: -------------------------------------------------------------------------------- 1 | /** @module RenderConstants */ 2 | 3 | /** 4 | * Various constants meant for use throughout the renderer. 5 | * @enum 6 | */ 7 | module.exports = { 8 | /** 9 | * The ID value to use for "no item" or when an object has been disposed. 10 | * @const {int} 11 | */ 12 | ID_NONE: -1, 13 | 14 | /** 15 | * Optimize for fewer than this number of Drawables sharing the same Skin. 16 | * Going above this may cause middleware warnings or a performance penalty but should otherwise behave correctly. 17 | * @const {int} 18 | */ 19 | SKIN_SHARE_SOFT_LIMIT: 301, 20 | 21 | /** 22 | * @enum {string} 23 | */ 24 | Events: { 25 | /** 26 | * NativeSizeChanged event 27 | * 28 | * @event RenderWebGL#event:NativeSizeChanged 29 | * @type {object} 30 | * @property {Array} newSize - the new size of the renderer 31 | */ 32 | NativeSizeChanged: 'NativeSizeChanged' 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/SVGSkin.js: -------------------------------------------------------------------------------- 1 | const twgl = require('twgl.js'); 2 | 3 | const Skin = require('./Skin'); 4 | const {loadSvgString, serializeSvgToString} = require('scratch-svg-renderer'); 5 | const ShaderManager = require('./ShaderManager'); 6 | 7 | const MAX_TEXTURE_DIMENSION = 2048; 8 | 9 | /** 10 | * All scaled renderings of the SVG are stored in an array. The 1.0 scale of 11 | * the SVG is stored at the 8th index. The smallest possible 1 / 256 scale 12 | * rendering is stored at the 0th index. 13 | * @const {number} 14 | */ 15 | const INDEX_OFFSET = 8; 16 | 17 | class SVGSkin extends Skin { 18 | /** 19 | * Create a new SVG skin. 20 | * @param {!int} id - The ID for this Skin. 21 | * @param {!RenderWebGL} renderer - The renderer which will use this skin. 22 | * @constructor 23 | * @extends Skin 24 | */ 25 | constructor (id, renderer) { 26 | super(id); 27 | 28 | /** @type {RenderWebGL} */ 29 | this._renderer = renderer; 30 | 31 | /** @type {HTMLImageElement} */ 32 | this._svgImage = document.createElement('img'); 33 | 34 | /** @type {boolean} */ 35 | this._svgImageLoaded = false; 36 | 37 | /** @type {Array} */ 38 | this._size = [0, 0]; 39 | 40 | /** @type {HTMLCanvasElement} */ 41 | this._canvas = document.createElement('canvas'); 42 | 43 | /** @type {CanvasRenderingContext2D} */ 44 | this._context = this._canvas.getContext('2d'); 45 | 46 | /** @type {Array} */ 47 | this._scaledMIPs = []; 48 | 49 | /** @type {number} */ 50 | this._largestMIPScale = 0; 51 | 52 | /** 53 | * Ratio of the size of the SVG and the max size of the WebGL texture 54 | * @type {Number} 55 | */ 56 | this._maxTextureScale = 1; 57 | } 58 | 59 | /** 60 | * Dispose of this object. Do not use it after calling this method. 61 | */ 62 | dispose () { 63 | this.resetMIPs(); 64 | super.dispose(); 65 | } 66 | 67 | /** 68 | * @return {Array} the natural size, in Scratch units, of this skin. 69 | */ 70 | get size () { 71 | return [this._size[0], this._size[1]]; 72 | } 73 | 74 | useNearest (scale, drawable) { 75 | // If the effect bits for mosaic, pixelate, whirl, or fisheye are set, use linear 76 | if ((drawable.enabledEffects & ( 77 | ShaderManager.EFFECT_INFO.fisheye.mask | 78 | ShaderManager.EFFECT_INFO.whirl.mask | 79 | ShaderManager.EFFECT_INFO.pixelate.mask | 80 | ShaderManager.EFFECT_INFO.mosaic.mask 81 | )) !== 0) { 82 | return false; 83 | } 84 | 85 | // We can't use nearest neighbor unless we are a multiple of 90 rotation 86 | if (drawable._direction % 90 !== 0) { 87 | return false; 88 | } 89 | 90 | // Because SVG skins' bounding boxes are currently not pixel-aligned, the idea here is to hide blurriness 91 | // by using nearest-neighbor scaling if one screen-space pixel is "close enough" to one texture pixel. 92 | // If the scale of the skin is very close to 100 (0.99999 variance is okay I guess) 93 | // TODO: Make this check more precise. We should use nearest if there's less than one pixel's difference 94 | // between the screen-space and texture-space sizes of the skin. Mipmaps make this harder because there are 95 | // multiple textures (and hence multiple texture spaces) and we need to know which one to choose. 96 | if (Math.abs(scale[0]) > 99 && Math.abs(scale[0]) < 101 && 97 | Math.abs(scale[1]) > 99 && Math.abs(scale[1]) < 101) { 98 | return true; 99 | } 100 | return false; 101 | } 102 | 103 | /** 104 | * Create a MIP for a given scale. 105 | * @param {number} scale - The relative size of the MIP 106 | * @return {SVGMIP} An object that handles creating and updating SVG textures. 107 | */ 108 | createMIP (scale) { 109 | const [width, height] = this._size; 110 | this._canvas.width = width * scale; 111 | this._canvas.height = height * scale; 112 | if ( 113 | this._canvas.width <= 0 || 114 | this._canvas.height <= 0 || 115 | // Even if the canvas at the current scale has a nonzero size, the image's dimensions are floored 116 | // pre-scaling; e.g. if an image has a width of 0.4 and is being rendered at 3x scale, the canvas will have 117 | // a width of 1, but the image's width will be rounded down to 0 on some browsers (Firefox) prior to being 118 | // drawn at that scale, resulting in an IndexSizeError if we attempt to draw it. 119 | this._svgImage.naturalWidth <= 0 || 120 | this._svgImage.naturalHeight <= 0 121 | ) return super.getTexture(); 122 | this._context.clearRect(0, 0, this._canvas.width, this._canvas.height); 123 | this._context.setTransform(scale, 0, 0, scale, 0, 0); 124 | this._context.drawImage(this._svgImage, 0, 0); 125 | 126 | // Pull out the ImageData from the canvas. ImageData speeds up 127 | // updating Silhouette and is better handled by more browsers in 128 | // regards to memory. 129 | const textureData = this._context.getImageData(0, 0, this._canvas.width, this._canvas.height); 130 | 131 | const textureOptions = { 132 | auto: false, 133 | wrap: this._renderer.gl.CLAMP_TO_EDGE, 134 | src: textureData, 135 | premultiplyAlpha: true 136 | }; 137 | 138 | const mip = twgl.createTexture(this._renderer.gl, textureOptions); 139 | 140 | // Check if this is the largest MIP created so far. Currently, silhouettes only get scaled up. 141 | if (this._largestMIPScale < scale) { 142 | this._silhouette.update(textureData); 143 | this._largestMIPScale = scale; 144 | } 145 | 146 | return mip; 147 | } 148 | 149 | updateSilhouette (scale = [100, 100]) { 150 | // Ensure a silhouette exists. 151 | this.getTexture(scale); 152 | } 153 | 154 | /** 155 | * @param {Array} scale - The scaling factors to be used, each in the [0,100] range. 156 | * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale. 157 | */ 158 | getTexture (scale) { 159 | // The texture only ever gets uniform scale. Take the larger of the two axes. 160 | const scaleMax = scale ? Math.max(Math.abs(scale[0]), Math.abs(scale[1])) : 100; 161 | const requestedScale = Math.min(scaleMax / 100, this._maxTextureScale); 162 | 163 | // Math.ceil(Math.log2(scale)) means we use the "1x" texture at (0.5, 1] scale, 164 | // the "2x" texture at (1, 2] scale, the "4x" texture at (2, 4] scale, etc. 165 | // This means that one texture pixel will always be between 0.5x and 1x the size of one rendered pixel, 166 | // but never bigger than one rendered pixel--this prevents blurriness from blowing up the texture too much. 167 | const mipLevel = Math.max(Math.ceil(Math.log2(requestedScale)) + INDEX_OFFSET, 0); 168 | // Can't use bitwise stuff here because we need to handle negative exponents 169 | const mipScale = Math.pow(2, mipLevel - INDEX_OFFSET); 170 | 171 | if (this._svgImageLoaded && !this._scaledMIPs[mipLevel]) { 172 | this._scaledMIPs[mipLevel] = this.createMIP(mipScale); 173 | } 174 | 175 | return this._scaledMIPs[mipLevel] || super.getTexture(); 176 | } 177 | 178 | /** 179 | * Do a hard reset of the existing MIPs by deleting them. 180 | */ 181 | resetMIPs () { 182 | this._scaledMIPs.forEach(oldMIP => this._renderer.gl.deleteTexture(oldMIP)); 183 | this._scaledMIPs.length = 0; 184 | this._largestMIPScale = 0; 185 | } 186 | 187 | /** 188 | * Set the contents of this skin to a snapshot of the provided SVG data. 189 | * @param {string} svgData - new SVG to use. 190 | * @param {Array} [rotationCenter] - Optional rotation center for the SVG. If not supplied, it will be 191 | * calculated from the bounding box 192 | * @fires Skin.event:WasAltered 193 | */ 194 | setSVG (svgData, rotationCenter) { 195 | const svgTag = loadSvgString(svgData); 196 | const svgText = serializeSvgToString(svgTag, true /* shouldInjectFonts */); 197 | this._svgImageLoaded = false; 198 | 199 | const {x, y, width, height} = svgTag.viewBox.baseVal; 200 | // While we're setting the size before the image is loaded, this doesn't cause the skin to appear with the wrong 201 | // size for a few frames while the new image is loading, because we don't emit the `WasAltered` event, telling 202 | // drawables using this skin to update, until the image is loaded. 203 | // We need to do this because the VM reads the skin's `size` directly after calling `setSVG`. 204 | // TODO: return a Promise so that the VM can read the skin's `size` after the image is loaded. 205 | this._size[0] = width; 206 | this._size[1] = height; 207 | 208 | // If there is another load already in progress, replace the old onload to effectively cancel the old load 209 | this._svgImage.onload = () => { 210 | if (width === 0 || height === 0) { 211 | super.setEmptyImageData(); 212 | return; 213 | } 214 | 215 | const maxDimension = Math.ceil(Math.max(width, height)); 216 | let testScale = 2; 217 | for (testScale; maxDimension * testScale <= MAX_TEXTURE_DIMENSION; testScale *= 2) { 218 | this._maxTextureScale = testScale; 219 | } 220 | 221 | this.resetMIPs(); 222 | 223 | if (typeof rotationCenter === 'undefined') rotationCenter = this.calculateRotationCenter(); 224 | // Compensate for viewbox offset. 225 | // See https://github.com/LLK/scratch-render/pull/90. 226 | this._rotationCenter[0] = rotationCenter[0] - x; 227 | this._rotationCenter[1] = rotationCenter[1] - y; 228 | 229 | this._svgImageLoaded = true; 230 | 231 | this.emit(Skin.Events.WasAltered); 232 | }; 233 | 234 | this._svgImage.src = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`; 235 | } 236 | 237 | } 238 | 239 | module.exports = SVGSkin; 240 | -------------------------------------------------------------------------------- /src/ShaderManager.js: -------------------------------------------------------------------------------- 1 | const twgl = require('twgl.js'); 2 | 3 | 4 | class ShaderManager { 5 | /** 6 | * @param {WebGLRenderingContext} gl WebGL rendering context to create shaders for 7 | * @constructor 8 | */ 9 | constructor (gl) { 10 | this._gl = gl; 11 | 12 | /** 13 | * The cache of all shaders compiled so far, filled on demand. 14 | * @type {Object>} 15 | * @private 16 | */ 17 | this._shaderCache = {}; 18 | for (const modeName in ShaderManager.DRAW_MODE) { 19 | if (Object.prototype.hasOwnProperty.call(ShaderManager.DRAW_MODE, modeName)) { 20 | this._shaderCache[modeName] = []; 21 | } 22 | } 23 | } 24 | 25 | /** 26 | * Fetch the shader for a particular set of active effects. 27 | * Build the shader if necessary. 28 | * @param {ShaderManager.DRAW_MODE} drawMode Draw normally, silhouette, etc. 29 | * @param {int} effectBits Bitmask representing the enabled effects. 30 | * @returns {ProgramInfo} The shader's program info. 31 | */ 32 | getShader (drawMode, effectBits) { 33 | const cache = this._shaderCache[drawMode]; 34 | if (drawMode === ShaderManager.DRAW_MODE.silhouette) { 35 | // Silhouette mode isn't affected by these effects. 36 | effectBits &= ~(ShaderManager.EFFECT_INFO.color.mask | ShaderManager.EFFECT_INFO.brightness.mask); 37 | } 38 | let shader = cache[effectBits]; 39 | if (!shader) { 40 | shader = cache[effectBits] = this._buildShader(drawMode, effectBits); 41 | } 42 | return shader; 43 | } 44 | 45 | /** 46 | * Build the shader for a particular set of active effects. 47 | * @param {ShaderManager.DRAW_MODE} drawMode Draw normally, silhouette, etc. 48 | * @param {int} effectBits Bitmask representing the enabled effects. 49 | * @returns {ProgramInfo} The new shader's program info. 50 | * @private 51 | */ 52 | _buildShader (drawMode, effectBits) { 53 | const numEffects = ShaderManager.EFFECTS.length; 54 | 55 | const defines = [ 56 | `#define DRAW_MODE_${drawMode}` 57 | ]; 58 | for (let index = 0; index < numEffects; ++index) { 59 | if ((effectBits & (1 << index)) !== 0) { 60 | defines.push(`#define ENABLE_${ShaderManager.EFFECTS[index]}`); 61 | } 62 | } 63 | 64 | const definesText = `${defines.join('\n')}\n`; 65 | 66 | /* eslint-disable global-require */ 67 | const vsFullText = definesText + require('raw-loader!./shaders/sprite.vert'); 68 | const fsFullText = definesText + require('raw-loader!./shaders/sprite.frag'); 69 | /* eslint-enable global-require */ 70 | 71 | return twgl.createProgramInfo(this._gl, [vsFullText, fsFullText]); 72 | } 73 | } 74 | 75 | /** 76 | * @typedef {object} ShaderManager.Effect 77 | * @prop {int} mask - The bit in 'effectBits' representing the effect. 78 | * @prop {function} converter - A conversion function which takes a Scratch value (generally in the range 79 | * 0..100 or -100..100) and maps it to a value useful to the shader. This 80 | * mapping may not be reversible. 81 | * @prop {boolean} shapeChanges - Whether the effect could change the drawn shape. 82 | */ 83 | 84 | /** 85 | * Mapping of each effect name to info about that effect. 86 | * @enum {ShaderManager.Effect} 87 | */ 88 | ShaderManager.EFFECT_INFO = { 89 | /** Color effect */ 90 | color: { 91 | uniformName: 'u_color', 92 | mask: 1 << 0, 93 | converter: x => (x / 200) % 1, 94 | shapeChanges: false 95 | }, 96 | /** Fisheye effect */ 97 | fisheye: { 98 | uniformName: 'u_fisheye', 99 | mask: 1 << 1, 100 | converter: x => Math.max(0, (x + 100) / 100), 101 | shapeChanges: true 102 | }, 103 | /** Whirl effect */ 104 | whirl: { 105 | uniformName: 'u_whirl', 106 | mask: 1 << 2, 107 | converter: x => -x * Math.PI / 180, 108 | shapeChanges: true 109 | }, 110 | /** Pixelate effect */ 111 | pixelate: { 112 | uniformName: 'u_pixelate', 113 | mask: 1 << 3, 114 | converter: x => Math.abs(x) / 10, 115 | shapeChanges: true 116 | }, 117 | /** Mosaic effect */ 118 | mosaic: { 119 | uniformName: 'u_mosaic', 120 | mask: 1 << 4, 121 | converter: x => { 122 | x = Math.round((Math.abs(x) + 10) / 10); 123 | /** @todo cap by Math.min(srcWidth, srcHeight) */ 124 | return Math.max(1, Math.min(x, 512)); 125 | }, 126 | shapeChanges: true 127 | }, 128 | /** Brightness effect */ 129 | brightness: { 130 | uniformName: 'u_brightness', 131 | mask: 1 << 5, 132 | converter: x => Math.max(-100, Math.min(x, 100)) / 100, 133 | shapeChanges: false 134 | }, 135 | /** Ghost effect */ 136 | ghost: { 137 | uniformName: 'u_ghost', 138 | mask: 1 << 6, 139 | converter: x => 1 - (Math.max(0, Math.min(x, 100)) / 100), 140 | shapeChanges: false 141 | } 142 | }; 143 | 144 | /** 145 | * The name of each supported effect. 146 | * @type {Array} 147 | */ 148 | ShaderManager.EFFECTS = Object.keys(ShaderManager.EFFECT_INFO); 149 | 150 | /** 151 | * The available draw modes. 152 | * @readonly 153 | * @enum {string} 154 | */ 155 | ShaderManager.DRAW_MODE = { 156 | /** 157 | * Draw normally. Its output will use premultiplied alpha. 158 | */ 159 | default: 'default', 160 | 161 | /** 162 | * Draw with non-premultiplied alpha. Useful for reading pixels from GL into an ImageData object. 163 | */ 164 | straightAlpha: 'straightAlpha', 165 | 166 | /** 167 | * Draw a silhouette using a solid color. 168 | */ 169 | silhouette: 'silhouette', 170 | 171 | /** 172 | * Draw only the parts of the drawable which match a particular color. 173 | */ 174 | colorMask: 'colorMask', 175 | 176 | /** 177 | * Draw a line with caps. 178 | */ 179 | line: 'line', 180 | 181 | /** 182 | * Draw the background in a certain color. Must sometimes be used instead of gl.clear. 183 | */ 184 | background: 'background' 185 | }; 186 | 187 | module.exports = ShaderManager; 188 | -------------------------------------------------------------------------------- /src/Silhouette.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 3 | * A representation of a Skin's silhouette that can test if a point on the skin 4 | * renders a pixel where it is drawn. 5 | */ 6 | 7 | /** 8 | * element used to update Silhouette data from skin bitmap data. 9 | * @type {CanvasElement} 10 | */ 11 | let __SilhouetteUpdateCanvas; 12 | 13 | // Optimized Math.min and Math.max for integers; 14 | // taken from https://web.archive.org/web/20190716181049/http://guihaire.com/code/?p=549 15 | const intMin = (i, j) => j ^ ((i ^ j) & ((i - j) >> 31)); 16 | const intMax = (i, j) => i ^ ((i ^ j) & ((i - j) >> 31)); 17 | 18 | /** 19 | * Internal helper function (in hopes that compiler can inline). Get a pixel 20 | * from silhouette data, or 0 if outside it's bounds. 21 | * @private 22 | * @param {Silhouette} silhouette - has data width and height 23 | * @param {number} x - x 24 | * @param {number} y - y 25 | * @return {number} Alpha value for x/y position 26 | */ 27 | const getPoint = ({_width: width, _height: height, _colorData: data}, x, y) => { 28 | // 0 if outside bounds, otherwise read from data. 29 | if (x >= width || y >= height || x < 0 || y < 0) { 30 | return 0; 31 | } 32 | return data[(((y * width) + x) * 4) + 3]; 33 | }; 34 | 35 | /** 36 | * Memory buffers for doing 4 corner sampling for linear interpolation 37 | */ 38 | const __cornerWork = [ 39 | new Uint8ClampedArray(4), 40 | new Uint8ClampedArray(4), 41 | new Uint8ClampedArray(4), 42 | new Uint8ClampedArray(4) 43 | ]; 44 | 45 | /** 46 | * Get the color from a given silhouette at an x/y local texture position. 47 | * Multiply color values by alpha for proper blending. 48 | * @param {Silhouette} $0 The silhouette to sample. 49 | * @param {number} x X position of texture [0, width). 50 | * @param {number} y Y position of texture [0, height). 51 | * @param {Uint8ClampedArray} dst A color 4b space. 52 | * @return {Uint8ClampedArray} The dst vector. 53 | */ 54 | const getColor4b = ({_width: width, _height: height, _colorData: data}, x, y, dst) => { 55 | // Clamp coords to edge, matching GL_CLAMP_TO_EDGE. 56 | // (See github.com/LLK/scratch-render/blob/954cfff02b08069a082cbedd415c1fecd9b1e4fb/src/BitmapSkin.js#L88) 57 | x = intMax(0, intMin(x, width - 1)); 58 | y = intMax(0, intMin(y, height - 1)); 59 | 60 | // 0 if outside bounds, otherwise read from data. 61 | if (x >= width || y >= height || x < 0 || y < 0) { 62 | return dst.fill(0); 63 | } 64 | const offset = ((y * width) + x) * 4; 65 | // premultiply alpha 66 | const alpha = data[offset + 3] / 255; 67 | dst[0] = data[offset] * alpha; 68 | dst[1] = data[offset + 1] * alpha; 69 | dst[2] = data[offset + 2] * alpha; 70 | dst[3] = data[offset + 3]; 71 | return dst; 72 | }; 73 | 74 | /** 75 | * Get the color from a given silhouette at an x/y local texture position. 76 | * Do not multiply color values by alpha, as it has already been done. 77 | * @param {Silhouette} $0 The silhouette to sample. 78 | * @param {number} x X position of texture [0, width). 79 | * @param {number} y Y position of texture [0, height). 80 | * @param {Uint8ClampedArray} dst A color 4b space. 81 | * @return {Uint8ClampedArray} The dst vector. 82 | */ 83 | const getPremultipliedColor4b = ({_width: width, _height: height, _colorData: data}, x, y, dst) => { 84 | // Clamp coords to edge, matching GL_CLAMP_TO_EDGE. 85 | x = intMax(0, intMin(x, width - 1)); 86 | y = intMax(0, intMin(y, height - 1)); 87 | 88 | const offset = ((y * width) + x) * 4; 89 | dst[0] = data[offset]; 90 | dst[1] = data[offset + 1]; 91 | dst[2] = data[offset + 2]; 92 | dst[3] = data[offset + 3]; 93 | return dst; 94 | }; 95 | 96 | class Silhouette { 97 | constructor () { 98 | /** 99 | * The width of the data representing the current skin data. 100 | * @type {number} 101 | */ 102 | this._width = 0; 103 | 104 | /** 105 | * The height of the data representing the current skin date. 106 | * @type {number} 107 | */ 108 | this._height = 0; 109 | 110 | /** 111 | * The data representing a skin's silhouette shape. 112 | * @type {Uint8ClampedArray} 113 | */ 114 | this._colorData = null; 115 | 116 | // By default, silhouettes are assumed not to contain premultiplied image data, 117 | // so when we get a color, we want to multiply it by its alpha channel. 118 | // Point `_getColor` to the version of the function that multiplies. 119 | this._getColor = getColor4b; 120 | 121 | this.colorAtNearest = this.colorAtLinear = (_, dst) => dst.fill(0); 122 | } 123 | 124 | /** 125 | * Update this silhouette with the bitmapData for a skin. 126 | * @param {ImageData|HTMLCanvasElement|HTMLImageElement} bitmapData An image, canvas or other element that the skin 127 | * @param {boolean} isPremultiplied True if the source bitmap data comes premultiplied (e.g. from readPixels). 128 | * rendering can be queried from. 129 | */ 130 | update (bitmapData, isPremultiplied = false) { 131 | let imageData; 132 | if (bitmapData instanceof ImageData) { 133 | // If handed ImageData directly, use it directly. 134 | imageData = bitmapData; 135 | this._width = bitmapData.width; 136 | this._height = bitmapData.height; 137 | } else { 138 | // Draw about anything else to our update canvas and poll image data 139 | // from that. 140 | const canvas = Silhouette._updateCanvas(); 141 | const width = this._width = canvas.width = bitmapData.width; 142 | const height = this._height = canvas.height = bitmapData.height; 143 | const ctx = canvas.getContext('2d'); 144 | 145 | if (!(width && height)) { 146 | return; 147 | } 148 | ctx.clearRect(0, 0, width, height); 149 | ctx.drawImage(bitmapData, 0, 0, width, height); 150 | imageData = ctx.getImageData(0, 0, width, height); 151 | } 152 | 153 | if (isPremultiplied) { 154 | this._getColor = getPremultipliedColor4b; 155 | } else { 156 | this._getColor = getColor4b; 157 | } 158 | 159 | this._colorData = imageData.data; 160 | // delete our custom overriden "uninitalized" color functions 161 | // let the prototype work for itself 162 | delete this.colorAtNearest; 163 | delete this.colorAtLinear; 164 | } 165 | 166 | /** 167 | * Sample a color from the silhouette at a given local position using 168 | * "nearest neighbor" 169 | * @param {twgl.v3} vec [x,y] texture space (0-1) 170 | * @param {Uint8ClampedArray} dst The memory buffer to store the value in. (4 bytes) 171 | * @returns {Uint8ClampedArray} dst 172 | */ 173 | colorAtNearest (vec, dst) { 174 | return this._getColor( 175 | this, 176 | Math.floor(vec[0] * (this._width - 1)), 177 | Math.floor(vec[1] * (this._height - 1)), 178 | dst 179 | ); 180 | } 181 | 182 | /** 183 | * Sample a color from the silhouette at a given local position using 184 | * "linear interpolation" 185 | * @param {twgl.v3} vec [x,y] texture space (0-1) 186 | * @param {Uint8ClampedArray} dst The memory buffer to store the value in. (4 bytes) 187 | * @returns {Uint8ClampedArray} dst 188 | */ 189 | colorAtLinear (vec, dst) { 190 | const x = vec[0] * (this._width - 1); 191 | const y = vec[1] * (this._height - 1); 192 | 193 | const x1D = x % 1; 194 | const y1D = y % 1; 195 | const x0D = 1 - x1D; 196 | const y0D = 1 - y1D; 197 | 198 | const xFloor = Math.floor(x); 199 | const yFloor = Math.floor(y); 200 | 201 | const x0y0 = this._getColor(this, xFloor, yFloor, __cornerWork[0]); 202 | const x1y0 = this._getColor(this, xFloor + 1, yFloor, __cornerWork[1]); 203 | const x0y1 = this._getColor(this, xFloor, yFloor + 1, __cornerWork[2]); 204 | const x1y1 = this._getColor(this, xFloor + 1, yFloor + 1, __cornerWork[3]); 205 | 206 | dst[0] = (x0y0[0] * x0D * y0D) + (x0y1[0] * x0D * y1D) + (x1y0[0] * x1D * y0D) + (x1y1[0] * x1D * y1D); 207 | dst[1] = (x0y0[1] * x0D * y0D) + (x0y1[1] * x0D * y1D) + (x1y0[1] * x1D * y0D) + (x1y1[1] * x1D * y1D); 208 | dst[2] = (x0y0[2] * x0D * y0D) + (x0y1[2] * x0D * y1D) + (x1y0[2] * x1D * y0D) + (x1y1[2] * x1D * y1D); 209 | dst[3] = (x0y0[3] * x0D * y0D) + (x0y1[3] * x0D * y1D) + (x1y0[3] * x1D * y0D) + (x1y1[3] * x1D * y1D); 210 | 211 | return dst; 212 | } 213 | 214 | /** 215 | * Test if texture coordinate touches the silhouette using nearest neighbor. 216 | * @param {twgl.v3} vec A texture coordinate. 217 | * @return {boolean} If the nearest pixel has an alpha value. 218 | */ 219 | isTouchingNearest (vec) { 220 | if (!this._colorData) return; 221 | return getPoint( 222 | this, 223 | Math.floor(vec[0] * (this._width - 1)), 224 | Math.floor(vec[1] * (this._height - 1)) 225 | ) > 0; 226 | } 227 | 228 | /** 229 | * Test to see if any of the 4 pixels used in the linear interpolate touch 230 | * the silhouette. 231 | * @param {twgl.v3} vec A texture coordinate. 232 | * @return {boolean} Any of the pixels have some alpha. 233 | */ 234 | isTouchingLinear (vec) { 235 | if (!this._colorData) return; 236 | const x = Math.floor(vec[0] * (this._width - 1)); 237 | const y = Math.floor(vec[1] * (this._height - 1)); 238 | return getPoint(this, x, y) > 0 || 239 | getPoint(this, x + 1, y) > 0 || 240 | getPoint(this, x, y + 1) > 0 || 241 | getPoint(this, x + 1, y + 1) > 0; 242 | } 243 | 244 | /** 245 | * Get the canvas element reused by Silhouettes to update their data with. 246 | * @private 247 | * @return {CanvasElement} A canvas to draw bitmap data to. 248 | */ 249 | static _updateCanvas () { 250 | if (typeof __SilhouetteUpdateCanvas === 'undefined') { 251 | __SilhouetteUpdateCanvas = document.createElement('canvas'); 252 | } 253 | return __SilhouetteUpdateCanvas; 254 | } 255 | } 256 | 257 | module.exports = Silhouette; 258 | -------------------------------------------------------------------------------- /src/Skin.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | 3 | const twgl = require('twgl.js'); 4 | 5 | const RenderConstants = require('./RenderConstants'); 6 | const Silhouette = require('./Silhouette'); 7 | 8 | class Skin extends EventEmitter { 9 | /** 10 | * Create a Skin, which stores and/or generates textures for use in rendering. 11 | * @param {int} id - The unique ID for this Skin. 12 | * @constructor 13 | */ 14 | constructor (id) { 15 | super(); 16 | 17 | /** @type {int} */ 18 | this._id = id; 19 | 20 | /** @type {Vec3} */ 21 | this._rotationCenter = twgl.v3.create(0, 0); 22 | 23 | /** @type {WebGLTexture} */ 24 | this._texture = null; 25 | 26 | /** 27 | * The uniforms to be used by the vertex and pixel shaders. 28 | * Some of these are used by other parts of the renderer as well. 29 | * @type {Object.} 30 | * @private 31 | */ 32 | this._uniforms = { 33 | /** 34 | * The nominal (not necessarily current) size of the current skin. 35 | * @type {Array} 36 | */ 37 | u_skinSize: [0, 0], 38 | 39 | /** 40 | * The actual WebGL texture object for the skin. 41 | * @type {WebGLTexture} 42 | */ 43 | u_skin: null 44 | }; 45 | 46 | /** 47 | * A silhouette to store touching data, skins are responsible for keeping it up to date. 48 | * @private 49 | */ 50 | this._silhouette = new Silhouette(); 51 | 52 | this.setMaxListeners(RenderConstants.SKIN_SHARE_SOFT_LIMIT); 53 | } 54 | 55 | /** 56 | * Dispose of this object. Do not use it after calling this method. 57 | */ 58 | dispose () { 59 | this._id = RenderConstants.ID_NONE; 60 | } 61 | 62 | /** 63 | * @return {int} the unique ID for this Skin. 64 | */ 65 | get id () { 66 | return this._id; 67 | } 68 | 69 | /** 70 | * @returns {Vec3} the origin, in object space, about which this Skin should rotate. 71 | */ 72 | get rotationCenter () { 73 | return this._rotationCenter; 74 | } 75 | 76 | /** 77 | * @abstract 78 | * @return {Array} the "native" size, in texels, of this skin. 79 | */ 80 | get size () { 81 | return [0, 0]; 82 | } 83 | 84 | /** 85 | * Should this skin's texture be filtered with nearest-neighbor or linear interpolation at the given scale? 86 | * @param {?Array} scale The screen-space X and Y scaling factors at which this skin's texture will be 87 | * displayed, as percentages (100 means 1 "native size" unit is 1 screen pixel; 200 means 2 screen pixels, etc). 88 | * @param {Drawable} drawable The drawable that this skin's texture will be applied to. 89 | * @return {boolean} True if this skin's texture, as returned by {@link getTexture}, should be filtered with 90 | * nearest-neighbor interpolation. 91 | */ 92 | // eslint-disable-next-line no-unused-vars 93 | useNearest (scale, drawable) { 94 | return true; 95 | } 96 | 97 | /** 98 | * Get the center of the current bounding box 99 | * @return {Array} the center of the current bounding box 100 | */ 101 | calculateRotationCenter () { 102 | return [this.size[0] / 2, this.size[1] / 2]; 103 | } 104 | 105 | /** 106 | * @abstract 107 | * @param {Array} scale - The scaling factors to be used. 108 | * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given size. 109 | */ 110 | // eslint-disable-next-line no-unused-vars 111 | getTexture (scale) { 112 | return this._emptyImageTexture; 113 | } 114 | 115 | /** 116 | * Get the bounds of the drawable for determining its fenced position. 117 | * @param {Array} drawable - The Drawable instance this skin is using. 118 | * @param {?Rectangle} result - Optional destination for bounds calculation. 119 | * @return {!Rectangle} The drawable's bounds. For compatibility with Scratch 2, we always use getAABB. 120 | */ 121 | getFenceBounds (drawable, result) { 122 | return drawable.getAABB(result); 123 | } 124 | 125 | /** 126 | * Update and returns the uniforms for this skin. 127 | * @param {Array} scale - The scaling factors to be used. 128 | * @returns {object.} the shader uniforms to be used when rendering with this Skin. 129 | */ 130 | getUniforms (scale) { 131 | this._uniforms.u_skin = this.getTexture(scale); 132 | this._uniforms.u_skinSize = this.size; 133 | return this._uniforms; 134 | } 135 | 136 | /** 137 | * If the skin defers silhouette operations until the last possible minute, 138 | * this will be called before isTouching uses the silhouette. 139 | * @abstract 140 | */ 141 | updateSilhouette () {} 142 | 143 | /** 144 | * Set this skin's texture to the given image. 145 | * @param {ImageData|HTMLCanvasElement} textureData - The canvas or image data to set the texture to. 146 | */ 147 | _setTexture (textureData) { 148 | const gl = this._renderer.gl; 149 | 150 | gl.bindTexture(gl.TEXTURE_2D, this._texture); 151 | // Premultiplied alpha is necessary for proper blending. 152 | // See http://www.realtimerendering.com/blog/gpus-prefer-premultiplication/ 153 | gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); 154 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData); 155 | gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); 156 | 157 | this._silhouette.update(textureData); 158 | } 159 | 160 | /** 161 | * Set the contents of this skin to an empty skin. 162 | * @fires Skin.event:WasAltered 163 | */ 164 | setEmptyImageData () { 165 | // Free up the current reference to the _texture 166 | this._texture = null; 167 | 168 | if (!this._emptyImageData) { 169 | // Create a transparent pixel 170 | this._emptyImageData = new ImageData(1, 1); 171 | 172 | // Create a new texture and update the silhouette 173 | const gl = this._renderer.gl; 174 | 175 | const textureOptions = { 176 | auto: true, 177 | wrap: gl.CLAMP_TO_EDGE, 178 | src: this._emptyImageData 179 | }; 180 | 181 | // Note: we're using _emptyImageTexture here instead of _texture 182 | // so that we can cache this empty texture for later use as needed. 183 | // this._texture can get modified by other skins (e.g. BitmapSkin 184 | // and SVGSkin, so we can't use that same field for caching) 185 | this._emptyImageTexture = twgl.createTexture(gl, textureOptions); 186 | } 187 | 188 | this._rotationCenter[0] = 0; 189 | this._rotationCenter[1] = 0; 190 | 191 | this._silhouette.update(this._emptyImageData); 192 | this.emit(Skin.Events.WasAltered); 193 | } 194 | 195 | /** 196 | * Does this point touch an opaque or translucent point on this skin? 197 | * Nearest Neighbor version 198 | * The caller is responsible for ensuring this skin's silhouette is up-to-date. 199 | * @see updateSilhouette 200 | * @see Drawable.updateCPURenderAttributes 201 | * @param {twgl.v3} vec A texture coordinate. 202 | * @return {boolean} Did it touch? 203 | */ 204 | isTouchingNearest (vec) { 205 | return this._silhouette.isTouchingNearest(vec); 206 | } 207 | 208 | /** 209 | * Does this point touch an opaque or translucent point on this skin? 210 | * Linear Interpolation version 211 | * The caller is responsible for ensuring this skin's silhouette is up-to-date. 212 | * @see updateSilhouette 213 | * @see Drawable.updateCPURenderAttributes 214 | * @param {twgl.v3} vec A texture coordinate. 215 | * @return {boolean} Did it touch? 216 | */ 217 | isTouchingLinear (vec) { 218 | return this._silhouette.isTouchingLinear(vec); 219 | } 220 | 221 | } 222 | 223 | /** 224 | * These are the events which can be emitted by instances of this class. 225 | * @enum {string} 226 | */ 227 | Skin.Events = { 228 | /** 229 | * Emitted when anything about the Skin has been altered, such as the appearance or rotation center. 230 | * @event Skin.event:WasAltered 231 | */ 232 | WasAltered: 'WasAltered' 233 | }; 234 | 235 | module.exports = Skin; 236 | -------------------------------------------------------------------------------- /src/TextBubbleSkin.js: -------------------------------------------------------------------------------- 1 | const twgl = require('twgl.js'); 2 | 3 | const TextWrapper = require('./util/text-wrapper'); 4 | const CanvasMeasurementProvider = require('./util/canvas-measurement-provider'); 5 | const Skin = require('./Skin'); 6 | 7 | const BubbleStyle = { 8 | MAX_LINE_WIDTH: 170, // Maximum width, in Scratch pixels, of a single line of text 9 | 10 | MIN_WIDTH: 50, // Minimum width, in Scratch pixels, of a text bubble 11 | STROKE_WIDTH: 4, // Thickness of the stroke around the bubble. Only half's visible because it's drawn under the fill 12 | PADDING: 10, // Padding around the text area 13 | CORNER_RADIUS: 16, // Radius of the rounded corners 14 | TAIL_HEIGHT: 12, // Height of the speech bubble's "tail". Probably should be a constant. 15 | 16 | FONT: 'Helvetica', // Font to render the text with 17 | FONT_SIZE: 14, // Font size, in Scratch pixels 18 | FONT_HEIGHT_RATIO: 0.9, // Height, in Scratch pixels, of the text, as a proportion of the font's size 19 | LINE_HEIGHT: 16, // Spacing between each line of text 20 | 21 | COLORS: { 22 | BUBBLE_FILL: 'white', 23 | BUBBLE_STROKE: 'rgba(0, 0, 0, 0.15)', 24 | TEXT_FILL: '#575E75' 25 | } 26 | }; 27 | 28 | class TextBubbleSkin extends Skin { 29 | /** 30 | * Create a new text bubble skin. 31 | * @param {!int} id - The ID for this Skin. 32 | * @param {!RenderWebGL} renderer - The renderer which will use this skin. 33 | * @constructor 34 | * @extends Skin 35 | */ 36 | constructor (id, renderer) { 37 | super(id); 38 | 39 | /** @type {RenderWebGL} */ 40 | this._renderer = renderer; 41 | 42 | /** @type {HTMLCanvasElement} */ 43 | this._canvas = document.createElement('canvas'); 44 | 45 | /** @type {Array} */ 46 | this._size = [0, 0]; 47 | 48 | /** @type {number} */ 49 | this._renderedScale = 0; 50 | 51 | /** @type {Array} */ 52 | this._lines = []; 53 | 54 | /** @type {object} */ 55 | this._textAreaSize = {width: 0, height: 0}; 56 | 57 | /** @type {string} */ 58 | this._bubbleType = ''; 59 | 60 | /** @type {boolean} */ 61 | this._pointsLeft = false; 62 | 63 | /** @type {boolean} */ 64 | this._textDirty = true; 65 | 66 | /** @type {boolean} */ 67 | this._textureDirty = true; 68 | 69 | this.measurementProvider = new CanvasMeasurementProvider(this._canvas.getContext('2d')); 70 | this.textWrapper = new TextWrapper(this.measurementProvider); 71 | 72 | this._restyleCanvas(); 73 | } 74 | 75 | /** 76 | * Dispose of this object. Do not use it after calling this method. 77 | */ 78 | dispose () { 79 | if (this._texture) { 80 | this._renderer.gl.deleteTexture(this._texture); 81 | this._texture = null; 82 | } 83 | this._canvas = null; 84 | super.dispose(); 85 | } 86 | 87 | /** 88 | * @return {Array} the dimensions, in Scratch units, of this skin. 89 | */ 90 | get size () { 91 | if (this._textDirty) { 92 | this._reflowLines(); 93 | } 94 | return this._size; 95 | } 96 | 97 | /** 98 | * Set parameters for this text bubble. 99 | * @param {!string} type - either "say" or "think". 100 | * @param {!string} text - the text for the bubble. 101 | * @param {!boolean} pointsLeft - which side the bubble is pointing. 102 | */ 103 | setTextBubble (type, text, pointsLeft) { 104 | this._text = text; 105 | this._bubbleType = type; 106 | this._pointsLeft = pointsLeft; 107 | 108 | this._textDirty = true; 109 | this._textureDirty = true; 110 | this.emit(Skin.Events.WasAltered); 111 | } 112 | 113 | /** 114 | * Re-style the canvas after resizing it. This is necessary to ensure proper text measurement. 115 | */ 116 | _restyleCanvas () { 117 | this._canvas.getContext('2d').font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`; 118 | } 119 | 120 | /** 121 | * Update the array of wrapped lines and the text dimensions. 122 | */ 123 | _reflowLines () { 124 | this._lines = this.textWrapper.wrapText(BubbleStyle.MAX_LINE_WIDTH, this._text); 125 | 126 | // Measure width of longest line to avoid extra-wide bubbles 127 | let longestLineWidth = 0; 128 | for (const line of this._lines) { 129 | longestLineWidth = Math.max(longestLineWidth, this.measurementProvider.measureText(line)); 130 | } 131 | 132 | // Calculate the canvas-space sizes of the padded text area and full text bubble 133 | const paddedWidth = Math.max(longestLineWidth, BubbleStyle.MIN_WIDTH) + (BubbleStyle.PADDING * 2); 134 | const paddedHeight = (BubbleStyle.LINE_HEIGHT * this._lines.length) + (BubbleStyle.PADDING * 2); 135 | 136 | this._textAreaSize.width = paddedWidth; 137 | this._textAreaSize.height = paddedHeight; 138 | 139 | this._size[0] = paddedWidth + BubbleStyle.STROKE_WIDTH; 140 | this._size[1] = paddedHeight + BubbleStyle.STROKE_WIDTH + BubbleStyle.TAIL_HEIGHT; 141 | 142 | this._textDirty = false; 143 | } 144 | 145 | /** 146 | * Render this text bubble at a certain scale, using the current parameters, to the canvas. 147 | * @param {number} scale The scale to render the bubble at 148 | */ 149 | _renderTextBubble (scale) { 150 | const ctx = this._canvas.getContext('2d'); 151 | 152 | if (this._textDirty) { 153 | this._reflowLines(); 154 | } 155 | 156 | // Calculate the canvas-space sizes of the padded text area and full text bubble 157 | const paddedWidth = this._textAreaSize.width; 158 | const paddedHeight = this._textAreaSize.height; 159 | 160 | // Resize the canvas to the correct screen-space size 161 | this._canvas.width = Math.ceil(this._size[0] * scale); 162 | this._canvas.height = Math.ceil(this._size[1] * scale); 163 | this._restyleCanvas(); 164 | 165 | // Reset the transform before clearing to ensure 100% clearage 166 | ctx.setTransform(1, 0, 0, 1, 0, 0); 167 | ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); 168 | 169 | ctx.scale(scale, scale); 170 | ctx.translate(BubbleStyle.STROKE_WIDTH * 0.5, BubbleStyle.STROKE_WIDTH * 0.5); 171 | 172 | // If the text bubble points leftward, flip the canvas 173 | ctx.save(); 174 | if (this._pointsLeft) { 175 | ctx.scale(-1, 1); 176 | ctx.translate(-paddedWidth, 0); 177 | } 178 | 179 | // Draw the bubble's rounded borders 180 | ctx.beginPath(); 181 | ctx.moveTo(BubbleStyle.CORNER_RADIUS, paddedHeight); 182 | ctx.arcTo(0, paddedHeight, 0, paddedHeight - BubbleStyle.CORNER_RADIUS, BubbleStyle.CORNER_RADIUS); 183 | ctx.arcTo(0, 0, paddedWidth, 0, BubbleStyle.CORNER_RADIUS); 184 | ctx.arcTo(paddedWidth, 0, paddedWidth, paddedHeight, BubbleStyle.CORNER_RADIUS); 185 | ctx.arcTo(paddedWidth, paddedHeight, paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight, 186 | BubbleStyle.CORNER_RADIUS); 187 | 188 | // Translate the canvas so we don't have to do a bunch of width/height arithmetic 189 | ctx.save(); 190 | ctx.translate(paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight); 191 | 192 | // Draw the bubble's "tail" 193 | if (this._bubbleType === 'say') { 194 | // For a speech bubble, draw one swoopy thing 195 | ctx.bezierCurveTo(0, 4, 4, 8, 4, 10); 196 | ctx.arcTo(4, 12, 2, 12, 2); 197 | ctx.bezierCurveTo(-1, 12, -11, 8, -16, 0); 198 | 199 | ctx.closePath(); 200 | } else { 201 | // For a thinking bubble, draw a partial circle attached to the bubble... 202 | ctx.arc(-16, 0, 4, 0, Math.PI); 203 | 204 | ctx.closePath(); 205 | 206 | // and two circles detached from it 207 | ctx.moveTo(-7, 7.25); 208 | ctx.arc(-9.25, 7.25, 2.25, 0, Math.PI * 2); 209 | 210 | ctx.moveTo(0, 9.5); 211 | ctx.arc(-1.5, 9.5, 1.5, 0, Math.PI * 2); 212 | } 213 | 214 | // Un-translate the canvas and fill + stroke the text bubble 215 | ctx.restore(); 216 | 217 | ctx.fillStyle = BubbleStyle.COLORS.BUBBLE_FILL; 218 | ctx.strokeStyle = BubbleStyle.COLORS.BUBBLE_STROKE; 219 | ctx.lineWidth = BubbleStyle.STROKE_WIDTH; 220 | 221 | ctx.stroke(); 222 | ctx.fill(); 223 | 224 | // Un-flip the canvas if it was flipped 225 | ctx.restore(); 226 | 227 | // Draw each line of text 228 | ctx.fillStyle = BubbleStyle.COLORS.TEXT_FILL; 229 | ctx.font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`; 230 | const lines = this._lines; 231 | for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { 232 | const line = lines[lineNumber]; 233 | ctx.fillText( 234 | line, 235 | BubbleStyle.PADDING, 236 | BubbleStyle.PADDING + (BubbleStyle.LINE_HEIGHT * lineNumber) + 237 | (BubbleStyle.FONT_HEIGHT_RATIO * BubbleStyle.FONT_SIZE) 238 | ); 239 | } 240 | 241 | this._renderedScale = scale; 242 | } 243 | 244 | updateSilhouette (scale = [100, 100]) { 245 | // Ensure a silhouette exists. 246 | this.getTexture(scale); 247 | } 248 | 249 | /** 250 | * @param {Array} scale - The scaling factors to be used, each in the [0,100] range. 251 | * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale. 252 | */ 253 | getTexture (scale) { 254 | // The texture only ever gets uniform scale. Take the larger of the two axes. 255 | const scaleMax = scale ? Math.max(Math.abs(scale[0]), Math.abs(scale[1])) : 100; 256 | const requestedScale = scaleMax / 100; 257 | 258 | // If we already rendered the text bubble at this scale, we can skip re-rendering it. 259 | if (this._textureDirty || this._renderedScale !== requestedScale) { 260 | this._renderTextBubble(requestedScale); 261 | this._textureDirty = false; 262 | 263 | const context = this._canvas.getContext('2d'); 264 | const textureData = context.getImageData(0, 0, this._canvas.width, this._canvas.height); 265 | 266 | const gl = this._renderer.gl; 267 | 268 | if (this._texture === null) { 269 | const textureOptions = { 270 | auto: false, 271 | wrap: gl.CLAMP_TO_EDGE 272 | }; 273 | 274 | this._texture = twgl.createTexture(gl, textureOptions); 275 | } 276 | 277 | this._setTexture(textureData); 278 | } 279 | 280 | return this._texture; 281 | } 282 | } 283 | 284 | module.exports = TextBubbleSkin; 285 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const RenderWebGL = require('./RenderWebGL'); 2 | 3 | /** 4 | * Export for NPM & Node.js 5 | * @type {RenderWebGL} 6 | */ 7 | module.exports = RenderWebGL; 8 | -------------------------------------------------------------------------------- /src/playground/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['scratch'], 3 | env: { 4 | browser: true 5 | }, 6 | rules: { 7 | 'no-console': 'off' 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/playground/getMousePosition.js: -------------------------------------------------------------------------------- 1 | // Adapted from code by Simon Sarris: http://stackoverflow.com/a/10450761 2 | const getMousePos = function (event, element) { 3 | const stylePaddingLeft = parseInt(document.defaultView.getComputedStyle(element, null).paddingLeft, 10) || 0; 4 | const stylePaddingTop = parseInt(document.defaultView.getComputedStyle(element, null).paddingTop, 10) || 0; 5 | const styleBorderLeft = parseInt(document.defaultView.getComputedStyle(element, null).borderLeftWidth, 10) || 0; 6 | const styleBorderTop = parseInt(document.defaultView.getComputedStyle(element, null).borderTopWidth, 10) || 0; 7 | 8 | // Some pages have fixed-position bars at the top or left of the page 9 | // They will mess up mouse coordinates and this fixes that 10 | const html = document.body.parentNode; 11 | const htmlTop = html.offsetTop; 12 | const htmlLeft = html.offsetLeft; 13 | 14 | // Compute the total offset. It's possible to cache this if you want 15 | let offsetX = 0; 16 | let offsetY = 0; 17 | if (typeof element.offsetParent !== 'undefined') { 18 | do { 19 | offsetX += element.offsetLeft; 20 | offsetY += element.offsetTop; 21 | } while ((element = element.offsetParent)); 22 | } 23 | 24 | // Add padding and border style widths to offset 25 | // Also add the offsets in case there's a position:fixed bar 26 | // This part is not strictly necessary, it depends on your styling 27 | offsetX += stylePaddingLeft + styleBorderLeft + htmlLeft; 28 | offsetY += stylePaddingTop + styleBorderTop + htmlTop; 29 | 30 | // We return a simple javascript object with x and y defined 31 | return { 32 | x: event.pageX - offsetX, 33 | y: event.pageY - offsetY 34 | }; 35 | }; 36 | 37 | module.exports = getMousePos; 38 | -------------------------------------------------------------------------------- /src/playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Scratch WebGL rendering demo 6 | 7 | 8 | 9 | 10 | 11 |

12 | 13 | 28 | 29 | 30 |

31 |

32 | 33 | 34 |

35 |

36 | 37 | 38 |

39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/playground/playground.js: -------------------------------------------------------------------------------- 1 | const ScratchRender = require('../RenderWebGL'); 2 | const getMousePosition = require('./getMousePosition'); 3 | 4 | const canvas = document.getElementById('scratch-stage'); 5 | let fudge = 90; 6 | const renderer = new ScratchRender(canvas); 7 | renderer.setLayerGroupOrdering(['group1']); 8 | 9 | const drawableID = renderer.createDrawable('group1'); 10 | renderer.updateDrawableProperties(drawableID, { 11 | position: [0, 0], 12 | scale: [100, 100], 13 | direction: 90 14 | }); 15 | 16 | const WantedSkinType = { 17 | bitmap: 'bitmap', 18 | vector: 'vector', 19 | pen: 'pen' 20 | }; 21 | 22 | const drawableID2 = renderer.createDrawable('group1'); 23 | const wantedSkin = WantedSkinType.vector; 24 | 25 | // Bitmap (squirrel) 26 | const image = new Image(); 27 | image.addEventListener('load', () => { 28 | const bitmapSkinId = renderer.createBitmapSkin(image); 29 | if (wantedSkin === WantedSkinType.bitmap) { 30 | renderer.updateDrawableProperties(drawableID2, { 31 | skinId: bitmapSkinId 32 | }); 33 | } 34 | }); 35 | image.crossOrigin = 'anonymous'; 36 | image.src = 'https://cdn.assets.scratch.mit.edu/internalapi/asset/7e24c99c1b853e52f8e7f9004416fa34.png/get/'; 37 | 38 | // SVG (cat 1-a) 39 | const xhr = new XMLHttpRequest(); 40 | xhr.addEventListener('load', () => { 41 | const skinId = renderer.createSVGSkin(xhr.responseText); 42 | if (wantedSkin === WantedSkinType.vector) { 43 | renderer.updateDrawableProperties(drawableID2, { 44 | skinId: skinId 45 | }); 46 | } 47 | }); 48 | xhr.open('GET', 'https://cdn.assets.scratch.mit.edu/internalapi/asset/b7853f557e4426412e64bb3da6531a99.svg/get/'); 49 | xhr.send(); 50 | 51 | if (wantedSkin === WantedSkinType.pen) { 52 | const penSkinID = renderer.createPenSkin(); 53 | 54 | renderer.updateDrawableProperties(drawableID2, { 55 | skinId: penSkinID 56 | }); 57 | 58 | canvas.addEventListener('click', event => { 59 | const rect = canvas.getBoundingClientRect(); 60 | 61 | const x = event.clientX - rect.left; 62 | const y = event.clientY - rect.top; 63 | 64 | renderer.penLine(penSkinID, { 65 | color4f: [Math.random(), Math.random(), Math.random(), 1], 66 | diameter: 8 67 | }, 68 | x - 240, 180 - y, (Math.random() * 480) - 240, (Math.random() * 360) - 180); 69 | }); 70 | } 71 | 72 | let posX = 0; 73 | let posY = 0; 74 | let scaleX = 100; 75 | let scaleY = 100; 76 | let fudgeProperty = 'posx'; 77 | 78 | const fudgeInput = document.getElementById('fudge'); 79 | const fudgePropertyInput = document.getElementById('fudgeproperty'); 80 | const fudgeMinInput = document.getElementById('fudgeMin'); 81 | const fudgeMaxInput = document.getElementById('fudgeMax'); 82 | 83 | /* eslint require-jsdoc: 0 */ 84 | const updateFudgeProperty = event => { 85 | fudgeProperty = event.target.value; 86 | }; 87 | 88 | const updateFudgeMin = event => { 89 | fudgeInput.min = event.target.valueAsNumber; 90 | }; 91 | 92 | const updateFudgeMax = event => { 93 | fudgeInput.max = event.target.valueAsNumber; 94 | }; 95 | 96 | fudgePropertyInput.addEventListener('change', updateFudgeProperty); 97 | fudgePropertyInput.addEventListener('init', updateFudgeProperty); 98 | 99 | fudgeMinInput.addEventListener('change', updateFudgeMin); 100 | fudgeMinInput.addEventListener('init', updateFudgeMin); 101 | 102 | fudgeMaxInput.addEventListener('change', updateFudgeMax); 103 | fudgeMaxInput.addEventListener('init', updateFudgeMax); 104 | 105 | // Ugly hack to properly set the values of the inputs on page load, 106 | // since they persist across reloads, at least in Firefox. 107 | // The best ugly hacks are the ones that reduce code duplication! 108 | fudgePropertyInput.dispatchEvent(new CustomEvent('init')); 109 | fudgeMinInput.dispatchEvent(new CustomEvent('init')); 110 | fudgeMaxInput.dispatchEvent(new CustomEvent('init')); 111 | fudgeInput.dispatchEvent(new CustomEvent('init')); 112 | 113 | const handleFudgeChanged = function (event) { 114 | fudge = event.target.valueAsNumber; 115 | const props = {}; 116 | switch (fudgeProperty) { 117 | case 'posx': 118 | props.position = [fudge, posY]; 119 | posX = fudge; 120 | break; 121 | case 'posy': 122 | props.position = [posX, fudge]; 123 | posY = fudge; 124 | break; 125 | case 'direction': 126 | props.direction = fudge; 127 | break; 128 | case 'scalex': 129 | props.scale = [fudge, scaleY]; 130 | scaleX = fudge; 131 | break; 132 | case 'scaley': 133 | props.scale = [scaleX, fudge]; 134 | scaleY = fudge; 135 | break; 136 | case 'scaleboth': 137 | props.scale = [fudge, fudge]; 138 | scaleX = fudge; 139 | scaleY = fudge; 140 | break; 141 | case 'color': 142 | props.color = fudge; 143 | break; 144 | case 'whirl': 145 | props.whirl = fudge; 146 | break; 147 | case 'fisheye': 148 | props.fisheye = fudge; 149 | break; 150 | case 'pixelate': 151 | props.pixelate = fudge; 152 | break; 153 | case 'mosaic': 154 | props.mosaic = fudge; 155 | break; 156 | case 'brightness': 157 | props.brightness = fudge; 158 | break; 159 | case 'ghost': 160 | props.ghost = fudge; 161 | break; 162 | } 163 | renderer.updateDrawableProperties(drawableID2, props); 164 | }; 165 | 166 | fudgeInput.addEventListener('input', handleFudgeChanged); 167 | fudgeInput.addEventListener('change', handleFudgeChanged); 168 | fudgeInput.addEventListener('init', handleFudgeChanged); 169 | 170 | const updateStageScale = event => { 171 | renderer.resize(480 * event.target.valueAsNumber, 360 * event.target.valueAsNumber); 172 | }; 173 | 174 | const stageScaleInput = document.getElementById('stage-scale'); 175 | 176 | stageScaleInput.addEventListener('input', updateStageScale); 177 | stageScaleInput.addEventListener('change', updateStageScale); 178 | 179 | canvas.addEventListener('mousemove', event => { 180 | const mousePos = getMousePosition(event, canvas); 181 | renderer.extractColor(mousePos.x, mousePos.y, 30); 182 | }); 183 | 184 | canvas.addEventListener('click', event => { 185 | const mousePos = getMousePosition(event, canvas); 186 | const pickID = renderer.pick(mousePos.x, mousePos.y); 187 | console.log(`You clicked on ${(pickID < 0 ? 'nothing' : `ID# ${pickID}`)}`); 188 | if (pickID >= 0) { 189 | console.dir(renderer.extractDrawableScreenSpace(pickID, mousePos.x, mousePos.y)); 190 | } 191 | }); 192 | 193 | const drawStep = function () { 194 | renderer.draw(); 195 | // renderer.getBounds(drawableID2); 196 | // renderer.isTouchingColor(drawableID2, [255,255,255]); 197 | requestAnimationFrame(drawStep); 198 | }; 199 | drawStep(); 200 | 201 | const debugCanvas = /** @type {canvas} */ document.getElementById('debug-canvas'); 202 | renderer.setDebugCanvas(debugCanvas); 203 | -------------------------------------------------------------------------------- /src/playground/queryPlayground.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Scratch WebGL Query Playground 6 | 7 | 24 | 25 | 26 |
27 |
28 | Query Canvases 29 | 30 | 31 | 39 | 47 | 48 |
32 |
33 | GPU 34 |
Touching color A? maybe
35 |
Touching color B? maybe
36 | 37 |
38 |
40 |
41 | CPU 42 |
Touching color A? maybe
43 |
Touching color B? maybe
44 | 45 |
46 |
49 |
50 |
51 | Render Canvas 52 |
Cursor Position: somewhere
53 | 54 | 55 | 56 | 59 | 60 | 61 | 64 | 67 | 68 |
57 | 58 |
62 | 63 | 65 | 66 |
69 |
70 |
71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/playground/queryPlayground.js: -------------------------------------------------------------------------------- 1 | const ScratchRender = require('../RenderWebGL'); 2 | const getMousePosition = require('./getMousePosition'); 3 | 4 | const renderCanvas = document.getElementById('renderCanvas'); 5 | const gpuQueryCanvas = document.getElementById('gpuQueryCanvas'); 6 | const cpuQueryCanvas = document.getElementById('cpuQueryCanvas'); 7 | const inputCursorX = document.getElementById('cursorX'); 8 | const inputCursorY = document.getElementById('cursorY'); 9 | const labelCursorPosition = document.getElementById('cursorPosition'); 10 | const labelGpuTouchingA = document.getElementById('gpuTouchingA'); 11 | const labelGpuTouchingB = document.getElementById('gpuTouchingB'); 12 | const labelCpuTouchingA = document.getElementById('cpuTouchingA'); 13 | const labelCpuTouchingB = document.getElementById('cpuTouchingB'); 14 | 15 | const drawables = { 16 | testPattern: -1, 17 | cursor: -1 18 | }; 19 | 20 | const colors = { 21 | cursor: [255, 0, 0], 22 | patternA: [0, 255, 0], 23 | patternB: [0, 0, 255] 24 | }; 25 | 26 | const renderer = new ScratchRender(renderCanvas); 27 | 28 | const handleResizeRenderCanvas = () => { 29 | const halfWidth = renderCanvas.clientWidth / 2; 30 | const halfHeight = renderCanvas.clientHeight / 2; 31 | 32 | inputCursorX.style.width = `${renderCanvas.clientWidth}px`; 33 | inputCursorY.style.height = `${renderCanvas.clientHeight}px`; 34 | inputCursorX.min = -halfWidth; 35 | inputCursorX.max = halfWidth; 36 | inputCursorY.min = -halfHeight; 37 | inputCursorY.max = halfHeight; 38 | }; 39 | renderCanvas.addEventListener('resize', handleResizeRenderCanvas); 40 | handleResizeRenderCanvas(); 41 | 42 | const handleCursorPositionChanged = () => { 43 | const devicePixelRatio = window.devicePixelRatio || 1; 44 | const cursorX = inputCursorX.valueAsNumber / devicePixelRatio; 45 | const cursorY = inputCursorY.valueAsNumber / devicePixelRatio; 46 | const positionHTML = `${cursorX}, ${cursorY}`; 47 | labelCursorPosition.innerHTML = positionHTML; 48 | if (drawables.cursor >= 0) { 49 | renderer.draw(); 50 | renderer.updateDrawableProperties(drawables.cursor, { 51 | position: [cursorX, cursorY] 52 | }); 53 | 54 | renderer.setUseGpuMode(ScratchRender.UseGpuModes.ForceGPU); 55 | renderer.setDebugCanvas(gpuQueryCanvas); 56 | const isGpuTouchingA = renderer.isTouchingColor(drawables.cursor, colors.patternA); 57 | const isGpuTouchingB = renderer.isTouchingColor(drawables.cursor, colors.patternB); 58 | labelGpuTouchingA.innerHTML = isGpuTouchingA ? 'yes' : 'no'; 59 | labelGpuTouchingB.innerHTML = isGpuTouchingB ? 'yes' : 'no'; 60 | 61 | renderer.setUseGpuMode(ScratchRender.UseGpuModes.ForceCPU); 62 | renderer.setDebugCanvas(cpuQueryCanvas); 63 | const isCpuTouchingA = renderer.isTouchingColor(drawables.cursor, colors.patternA); 64 | const isCpuTouchingB = renderer.isTouchingColor(drawables.cursor, colors.patternB); 65 | labelCpuTouchingA.innerHTML = isCpuTouchingA ? 'yes' : 'no'; 66 | labelCpuTouchingB.innerHTML = isCpuTouchingB ? 'yes' : 'no'; 67 | 68 | renderer.setUseGpuMode(ScratchRender.UseGpuModes.Automatic); 69 | } 70 | }; 71 | inputCursorX.addEventListener('change', handleCursorPositionChanged); 72 | inputCursorY.addEventListener('change', handleCursorPositionChanged); 73 | inputCursorX.addEventListener('input', handleCursorPositionChanged); 74 | inputCursorY.addEventListener('input', handleCursorPositionChanged); 75 | handleCursorPositionChanged(); 76 | 77 | let trackingMouse = true; 78 | const handleMouseMove = event => { 79 | if (trackingMouse) { 80 | const mousePosition = getMousePosition(event, renderCanvas); 81 | inputCursorX.value = mousePosition.x - (renderCanvas.clientWidth / 2); 82 | inputCursorY.value = (renderCanvas.clientHeight / 2) - mousePosition.y; 83 | handleCursorPositionChanged(); 84 | } 85 | }; 86 | renderCanvas.addEventListener('mousemove', handleMouseMove); 87 | 88 | renderCanvas.addEventListener('click', event => { 89 | trackingMouse = !trackingMouse; 90 | if (trackingMouse) { 91 | handleMouseMove(event); 92 | } 93 | }); 94 | 95 | const rgb2fillStyle = rgb => ( 96 | `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` 97 | ); 98 | 99 | const makeCursorImage = () => { 100 | const canvas = document.createElement('canvas'); 101 | canvas.width = canvas.height = 1; 102 | 103 | const context = canvas.getContext('2d'); 104 | context.fillStyle = rgb2fillStyle(colors.cursor); 105 | context.fillRect(0, 0, 1, 1); 106 | 107 | return canvas; 108 | }; 109 | 110 | const makeTestPatternImage = () => { 111 | const canvas = document.createElement('canvas'); 112 | canvas.width = 480; 113 | canvas.height = 360; 114 | 115 | const patternA = rgb2fillStyle(colors.patternA); 116 | const patternB = rgb2fillStyle(colors.patternB); 117 | 118 | const context = canvas.getContext('2d'); 119 | context.fillStyle = patternA; 120 | context.fillRect(0, 0, canvas.width, canvas.height); 121 | 122 | context.fillStyle = patternB; 123 | const xSplit1 = Math.floor(canvas.width * 0.25); 124 | const xSplit2 = Math.floor(canvas.width * 0.5); 125 | const xSplit3 = Math.floor(canvas.width * 0.75); 126 | const ySplit = Math.floor(canvas.height * 0.5); 127 | for (let y = 0; y < ySplit; y += 2) { 128 | context.fillRect(0, y, xSplit2, 1); 129 | } 130 | for (let x = xSplit2; x < canvas.width; x += 2) { 131 | context.fillRect(x, 0, 1, ySplit); 132 | } 133 | for (let x = 0; x < xSplit1; x += 2) { 134 | for (let y = ySplit; y < canvas.height; y += 2) { 135 | context.fillRect(x, y, 1, 1); 136 | } 137 | } 138 | for (let x = xSplit1; x < xSplit2; x += 3) { 139 | for (let y = ySplit; y < canvas.height; y += 3) { 140 | context.fillRect(x, y, 2, 2); 141 | } 142 | } 143 | for (let x = xSplit2; x < xSplit3; ++x) { 144 | for (let y = ySplit; y < canvas.height; ++y) { 145 | context.fillStyle = (x + y) % 2 ? patternB : patternA; 146 | context.fillRect(x, y, 1, 1); 147 | } 148 | } 149 | for (let x = xSplit3; x < canvas.width; x += 2) { 150 | for (let y = ySplit; y < canvas.height; y += 2) { 151 | context.fillStyle = (x + y) % 4 ? patternB : patternA; 152 | context.fillRect(x, y, 2, 2); 153 | } 154 | } 155 | 156 | return canvas; 157 | }; 158 | 159 | const makeTestPatternDrawable = function (group) { 160 | const image = makeTestPatternImage(); 161 | const skinId = renderer.createBitmapSkin(image, 1); 162 | const drawableId = renderer.createDrawable(group); 163 | renderer.updateDrawableProperties(drawableId, {skinId}); 164 | return drawableId; 165 | }; 166 | 167 | const makeCursorDrawable = function (group) { 168 | const image = makeCursorImage(); 169 | const skinId = renderer.createBitmapSkin(image, 1, [0, 0]); 170 | const drawableId = renderer.createDrawable(group); 171 | renderer.updateDrawableProperties(drawableId, {skinId}); 172 | return drawableId; 173 | }; 174 | 175 | const initRendering = () => { 176 | const layerGroup = { 177 | testPattern: 'testPattern', 178 | cursor: 'cursor' 179 | }; 180 | renderer.setLayerGroupOrdering([layerGroup.testPattern, layerGroup.cursor]); 181 | drawables.testPattern = makeTestPatternDrawable(layerGroup.testPattern); 182 | drawables.cursor = makeCursorDrawable(layerGroup.cursor); 183 | 184 | const corner00 = makeCursorDrawable(layerGroup.cursor); 185 | const corner01 = makeCursorDrawable(layerGroup.cursor); 186 | const corner10 = makeCursorDrawable(layerGroup.cursor); 187 | const corner11 = makeCursorDrawable(layerGroup.cursor); 188 | 189 | renderer.updateDrawableProperties(corner00, {position: [-240, -179]}); 190 | renderer.updateDrawableProperties(corner01, {position: [-240, 180]}); 191 | renderer.updateDrawableProperties(corner10, {position: [239, -179]}); 192 | renderer.updateDrawableProperties(corner11, {position: [239, 180]}); 193 | }; 194 | 195 | initRendering(); 196 | renderer.draw(); 197 | -------------------------------------------------------------------------------- /src/playground/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: lightsteelblue; 3 | } 4 | 5 | canvas { 6 | border: 3px dashed black; 7 | } 8 | 9 | #debug-canvas { 10 | border-color: red; 11 | } -------------------------------------------------------------------------------- /src/shaders/sprite.frag: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | 3 | #ifdef DRAW_MODE_silhouette 4 | uniform vec4 u_silhouetteColor; 5 | #else // DRAW_MODE_silhouette 6 | # ifdef ENABLE_color 7 | uniform float u_color; 8 | # endif // ENABLE_color 9 | # ifdef ENABLE_brightness 10 | uniform float u_brightness; 11 | # endif // ENABLE_brightness 12 | #endif // DRAW_MODE_silhouette 13 | 14 | #ifdef DRAW_MODE_colorMask 15 | uniform vec3 u_colorMask; 16 | uniform float u_colorMaskTolerance; 17 | #endif // DRAW_MODE_colorMask 18 | 19 | #ifdef ENABLE_fisheye 20 | uniform float u_fisheye; 21 | #endif // ENABLE_fisheye 22 | #ifdef ENABLE_whirl 23 | uniform float u_whirl; 24 | #endif // ENABLE_whirl 25 | #ifdef ENABLE_pixelate 26 | uniform float u_pixelate; 27 | uniform vec2 u_skinSize; 28 | #endif // ENABLE_pixelate 29 | #ifdef ENABLE_mosaic 30 | uniform float u_mosaic; 31 | #endif // ENABLE_mosaic 32 | #ifdef ENABLE_ghost 33 | uniform float u_ghost; 34 | #endif // ENABLE_ghost 35 | 36 | #ifdef DRAW_MODE_line 37 | uniform vec4 u_lineColor; 38 | uniform float u_lineThickness; 39 | uniform float u_lineLength; 40 | #endif // DRAW_MODE_line 41 | 42 | #ifdef DRAW_MODE_background 43 | uniform vec4 u_backgroundColor; 44 | #endif // DRAW_MODE_background 45 | 46 | uniform sampler2D u_skin; 47 | 48 | #ifndef DRAW_MODE_background 49 | varying vec2 v_texCoord; 50 | #endif 51 | 52 | // Add this to divisors to prevent division by 0, which results in NaNs propagating through calculations. 53 | // Smaller values can cause problems on some mobile devices. 54 | const float epsilon = 1e-3; 55 | 56 | #if !defined(DRAW_MODE_silhouette) && (defined(ENABLE_color)) 57 | // Branchless color conversions based on code from: 58 | // http://www.chilliant.com/rgb2hsv.html by Ian Taylor 59 | // Based in part on work by Sam Hocevar and Emil Persson 60 | // See also: https://en.wikipedia.org/wiki/HSL_and_HSV#Formal_derivation 61 | 62 | 63 | // Convert an RGB color to Hue, Saturation, and Value. 64 | // All components of input and output are expected to be in the [0,1] range. 65 | vec3 convertRGB2HSV(vec3 rgb) 66 | { 67 | // Hue calculation has 3 cases, depending on which RGB component is largest, and one of those cases involves a "mod" 68 | // operation. In order to avoid that "mod" we split the M==R case in two: one for GG. The B>G case 69 | // will be calculated in the negative and fed through abs() in the hue calculation at the end. 70 | // See also: https://en.wikipedia.org/wiki/HSL_and_HSV#Hue_and_chroma 71 | const vec4 hueOffsets = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); 72 | 73 | // temp1.xy = sort B & G (largest first) 74 | // temp1.z = the hue offset we'll use if it turns out that R is the largest component (M==R) 75 | // temp1.w = the hue offset we'll use if it turns out that R is not the largest component (M==G or M==B) 76 | vec4 temp1 = rgb.b > rgb.g ? vec4(rgb.bg, hueOffsets.wz) : vec4(rgb.gb, hueOffsets.xy); 77 | 78 | // temp2.x = the largest component of RGB ("M" / "Max") 79 | // temp2.yw = the smaller components of RGB, ordered for the hue calculation (not necessarily sorted by magnitude!) 80 | // temp2.z = the hue offset we'll use in the hue calculation 81 | vec4 temp2 = rgb.r > temp1.x ? vec4(rgb.r, temp1.yzx) : vec4(temp1.xyw, rgb.r); 82 | 83 | // m = the smallest component of RGB ("min") 84 | float m = min(temp2.y, temp2.w); 85 | 86 | // Chroma = M - m 87 | float C = temp2.x - m; 88 | 89 | // Value = M 90 | float V = temp2.x; 91 | 92 | return vec3( 93 | abs(temp2.z + (temp2.w - temp2.y) / (6.0 * C + epsilon)), // Hue 94 | C / (temp2.x + epsilon), // Saturation 95 | V); // Value 96 | } 97 | 98 | vec3 convertHue2RGB(float hue) 99 | { 100 | float r = abs(hue * 6.0 - 3.0) - 1.0; 101 | float g = 2.0 - abs(hue * 6.0 - 2.0); 102 | float b = 2.0 - abs(hue * 6.0 - 4.0); 103 | return clamp(vec3(r, g, b), 0.0, 1.0); 104 | } 105 | 106 | vec3 convertHSV2RGB(vec3 hsv) 107 | { 108 | vec3 rgb = convertHue2RGB(hsv.x); 109 | float c = hsv.z * hsv.y; 110 | return rgb * c + hsv.z - c; 111 | } 112 | #endif // !defined(DRAW_MODE_silhouette) && (defined(ENABLE_color)) 113 | 114 | const vec2 kCenter = vec2(0.5, 0.5); 115 | 116 | void main() 117 | { 118 | #if !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background)) 119 | vec2 texcoord0 = v_texCoord; 120 | 121 | #ifdef ENABLE_mosaic 122 | texcoord0 = fract(u_mosaic * texcoord0); 123 | #endif // ENABLE_mosaic 124 | 125 | #ifdef ENABLE_pixelate 126 | { 127 | // TODO: clean up "pixel" edges 128 | vec2 pixelTexelSize = u_skinSize / u_pixelate; 129 | texcoord0 = (floor(texcoord0 * pixelTexelSize) + kCenter) / pixelTexelSize; 130 | } 131 | #endif // ENABLE_pixelate 132 | 133 | #ifdef ENABLE_whirl 134 | { 135 | const float kRadius = 0.5; 136 | vec2 offset = texcoord0 - kCenter; 137 | float offsetMagnitude = length(offset); 138 | float whirlFactor = max(1.0 - (offsetMagnitude / kRadius), 0.0); 139 | float whirlActual = u_whirl * whirlFactor * whirlFactor; 140 | float sinWhirl = sin(whirlActual); 141 | float cosWhirl = cos(whirlActual); 142 | mat2 rotationMatrix = mat2( 143 | cosWhirl, -sinWhirl, 144 | sinWhirl, cosWhirl 145 | ); 146 | 147 | texcoord0 = rotationMatrix * offset + kCenter; 148 | } 149 | #endif // ENABLE_whirl 150 | 151 | #ifdef ENABLE_fisheye 152 | { 153 | vec2 vec = (texcoord0 - kCenter) / kCenter; 154 | float vecLength = length(vec); 155 | float r = pow(min(vecLength, 1.0), u_fisheye) * max(1.0, vecLength); 156 | vec2 unit = vec / vecLength; 157 | 158 | texcoord0 = kCenter + r * unit * kCenter; 159 | } 160 | #endif // ENABLE_fisheye 161 | 162 | gl_FragColor = texture2D(u_skin, texcoord0); 163 | 164 | #if defined(ENABLE_color) || defined(ENABLE_brightness) 165 | // Divide premultiplied alpha values for proper color processing 166 | // Add epsilon to avoid dividing by 0 for fully transparent pixels 167 | gl_FragColor.rgb = clamp(gl_FragColor.rgb / (gl_FragColor.a + epsilon), 0.0, 1.0); 168 | 169 | #ifdef ENABLE_color 170 | { 171 | vec3 hsv = convertRGB2HSV(gl_FragColor.xyz); 172 | 173 | // this code forces grayscale values to be slightly saturated 174 | // so that some slight change of hue will be visible 175 | const float minLightness = 0.11 / 2.0; 176 | const float minSaturation = 0.09; 177 | if (hsv.z < minLightness) hsv = vec3(0.0, 1.0, minLightness); 178 | else if (hsv.y < minSaturation) hsv = vec3(0.0, minSaturation, hsv.z); 179 | 180 | hsv.x = mod(hsv.x + u_color, 1.0); 181 | if (hsv.x < 0.0) hsv.x += 1.0; 182 | 183 | gl_FragColor.rgb = convertHSV2RGB(hsv); 184 | } 185 | #endif // ENABLE_color 186 | 187 | #ifdef ENABLE_brightness 188 | gl_FragColor.rgb = clamp(gl_FragColor.rgb + vec3(u_brightness), vec3(0), vec3(1)); 189 | #endif // ENABLE_brightness 190 | 191 | // Re-multiply color values 192 | gl_FragColor.rgb *= gl_FragColor.a + epsilon; 193 | 194 | #endif // defined(ENABLE_color) || defined(ENABLE_brightness) 195 | 196 | #ifdef ENABLE_ghost 197 | gl_FragColor *= u_ghost; 198 | #endif // ENABLE_ghost 199 | 200 | #ifdef DRAW_MODE_silhouette 201 | // Discard fully transparent pixels for stencil test 202 | if (gl_FragColor.a == 0.0) { 203 | discard; 204 | } 205 | // switch to u_silhouetteColor only AFTER the alpha test 206 | gl_FragColor = u_silhouetteColor; 207 | #else // DRAW_MODE_silhouette 208 | 209 | #ifdef DRAW_MODE_colorMask 210 | vec3 maskDistance = abs(gl_FragColor.rgb - u_colorMask); 211 | vec3 colorMaskTolerance = vec3(u_colorMaskTolerance, u_colorMaskTolerance, u_colorMaskTolerance); 212 | if (any(greaterThan(maskDistance, colorMaskTolerance))) 213 | { 214 | discard; 215 | } 216 | #endif // DRAW_MODE_colorMask 217 | #endif // DRAW_MODE_silhouette 218 | 219 | #ifdef DRAW_MODE_straightAlpha 220 | // Un-premultiply alpha. 221 | gl_FragColor.rgb /= gl_FragColor.a + epsilon; 222 | #endif 223 | 224 | #endif // !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background)) 225 | 226 | #ifdef DRAW_MODE_line 227 | // Maaaaagic antialiased-line-with-round-caps shader. 228 | 229 | // "along-the-lineness". This increases parallel to the line. 230 | // It goes from negative before the start point, to 0.5 through the start to the end, then ramps up again 231 | // past the end point. 232 | float d = ((v_texCoord.x - clamp(v_texCoord.x, 0.0, u_lineLength)) * 0.5) + 0.5; 233 | 234 | // Distance from (0.5, 0.5) to (d, the perpendicular coordinate). When we're in the middle of the line, 235 | // d will be 0.5, so the distance will be 0 at points close to the line and will grow at points further from it. 236 | // For the "caps", d will ramp down/up, giving us rounding. 237 | // See https://www.youtube.com/watch?v=PMltMdi1Wzg for a rough outline of the technique used to round the lines. 238 | float line = distance(vec2(0.5), vec2(d, v_texCoord.y)) * 2.0; 239 | // Expand out the line by its thickness. 240 | line -= ((u_lineThickness - 1.0) * 0.5); 241 | // Because "distance to the center of the line" decreases the closer we get to the line, but we want more opacity 242 | // the closer we are to the line, invert it. 243 | gl_FragColor = u_lineColor * clamp(1.0 - line, 0.0, 1.0); 244 | #endif // DRAW_MODE_line 245 | 246 | #ifdef DRAW_MODE_background 247 | gl_FragColor = u_backgroundColor; 248 | #endif 249 | } 250 | -------------------------------------------------------------------------------- /src/shaders/sprite.vert: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | 3 | #ifdef DRAW_MODE_line 4 | uniform vec2 u_stageSize; 5 | uniform float u_lineThickness; 6 | uniform float u_lineLength; 7 | // The X and Y components of u_penPoints hold the first pen point. The Z and W components hold the difference between 8 | // the second pen point and the first. This is done because calculating the difference in the shader leads to floating- 9 | // point error when both points have large-ish coordinates. 10 | uniform vec4 u_penPoints; 11 | 12 | // Add this to divisors to prevent division by 0, which results in NaNs propagating through calculations. 13 | // Smaller values can cause problems on some mobile devices. 14 | const float epsilon = 1e-3; 15 | #endif 16 | 17 | #if !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background)) 18 | uniform mat4 u_projectionMatrix; 19 | uniform mat4 u_modelMatrix; 20 | attribute vec2 a_texCoord; 21 | #endif 22 | 23 | attribute vec2 a_position; 24 | 25 | varying vec2 v_texCoord; 26 | 27 | void main() { 28 | #ifdef DRAW_MODE_line 29 | // Calculate a rotated ("tight") bounding box around the two pen points. 30 | // Yes, we're doing this 6 times (once per vertex), but on actual GPU hardware, 31 | // it's still faster than doing it in JS combined with the cost of uniformMatrix4fv. 32 | 33 | // Expand line bounds by sqrt(2) / 2 each side-- this ensures that all antialiased pixels 34 | // fall within the quad, even at a 45-degree diagonal 35 | vec2 position = a_position; 36 | float expandedRadius = (u_lineThickness * 0.5) + 1.4142135623730951; 37 | 38 | // The X coordinate increases along the length of the line. It's 0 at the center of the origin point 39 | // and is in pixel-space (so at n pixels along the line, its value is n). 40 | v_texCoord.x = mix(0.0, u_lineLength + (expandedRadius * 2.0), a_position.x) - expandedRadius; 41 | // The Y coordinate is perpendicular to the line. It's also in pixel-space. 42 | v_texCoord.y = ((a_position.y - 0.5) * expandedRadius) + 0.5; 43 | 44 | position.x *= u_lineLength + (2.0 * expandedRadius); 45 | position.y *= 2.0 * expandedRadius; 46 | 47 | // 1. Center around first pen point 48 | position -= expandedRadius; 49 | 50 | // 2. Rotate quad to line angle 51 | vec2 pointDiff = u_penPoints.zw; 52 | // Ensure line has a nonzero length so it's rendered properly 53 | // As long as either component is nonzero, the line length will be nonzero 54 | // If the line is zero-length, give it a bit of horizontal length 55 | pointDiff.x = (abs(pointDiff.x) < epsilon && abs(pointDiff.y) < epsilon) ? epsilon : pointDiff.x; 56 | // The `normalized` vector holds rotational values equivalent to sine/cosine 57 | // We're applying the standard rotation matrix formula to the position to rotate the quad to the line angle 58 | // pointDiff can hold large values so we must divide by u_lineLength instead of calling GLSL's normalize function: 59 | // https://asawicki.info/news_1596_watch_out_for_reduced_precision_normalizelength_in_opengl_es 60 | vec2 normalized = pointDiff / max(u_lineLength, epsilon); 61 | position = mat2(normalized.x, normalized.y, -normalized.y, normalized.x) * position; 62 | 63 | // 3. Translate quad 64 | position += u_penPoints.xy; 65 | 66 | // 4. Apply view transform 67 | position *= 2.0 / u_stageSize; 68 | gl_Position = vec4(position, 0, 1); 69 | #elif defined(DRAW_MODE_background) 70 | gl_Position = vec4(a_position * 2.0, 0, 1); 71 | #else 72 | gl_Position = u_projectionMatrix * u_modelMatrix * vec4(a_position, 0, 1); 73 | v_texCoord = a_texCoord; 74 | #endif 75 | } 76 | -------------------------------------------------------------------------------- /src/util/canvas-measurement-provider.js: -------------------------------------------------------------------------------- 1 | class CanvasMeasurementProvider { 2 | /** 3 | * @param {CanvasRenderingContext2D} ctx - provides a canvas rendering context 4 | * with 'font' set to the text style of the text to be wrapped. 5 | */ 6 | constructor (ctx) { 7 | this._ctx = ctx; 8 | this._cache = {}; 9 | } 10 | 11 | 12 | // We don't need to set up or tear down anything here. Should these be removed altogether? 13 | 14 | /** 15 | * Called by the TextWrapper before a batch of zero or more calls to measureText(). 16 | */ 17 | beginMeasurementSession () { 18 | 19 | } 20 | 21 | /** 22 | * Called by the TextWrapper after a batch of zero or more calls to measureText(). 23 | */ 24 | endMeasurementSession () { 25 | 26 | } 27 | 28 | /** 29 | * Measure a whole string as one unit. 30 | * @param {string} text - the text to measure. 31 | * @returns {number} - the length of the string. 32 | */ 33 | measureText (text) { 34 | if (!this._cache[text]) { 35 | this._cache[text] = this._ctx.measureText(text).width; 36 | } 37 | return this._cache[text]; 38 | } 39 | } 40 | 41 | module.exports = CanvasMeasurementProvider; 42 | -------------------------------------------------------------------------------- /src/util/color-conversions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts an RGB color value to HSV. Conversion formula 3 | * adapted from http://lolengine.net/blog/2013/01/13/fast-rgb-to-hsv. 4 | * Assumes r, g, and b are in the range [0, 255] and 5 | * returns h, s, and v in the range [0, 1]. 6 | * 7 | * @param {Array} rgb The RGB color value 8 | * @param {number} rgb.r The red color value 9 | * @param {number} rgb.g The green color value 10 | * @param {number} rgb.b The blue color value 11 | * @param {Array} dst The array to store the HSV values in 12 | * @return {Array} The `dst` array passed in 13 | */ 14 | const rgbToHsv = ([r, g, b], dst) => { 15 | let K = 0.0; 16 | 17 | r /= 255; 18 | g /= 255; 19 | b /= 255; 20 | let tmp = 0; 21 | 22 | if (g < b) { 23 | tmp = g; 24 | g = b; 25 | b = tmp; 26 | 27 | K = -1; 28 | } 29 | 30 | if (r < g) { 31 | tmp = r; 32 | r = g; 33 | g = tmp; 34 | 35 | K = (-2 / 6) - K; 36 | } 37 | 38 | const chroma = r - Math.min(g, b); 39 | const h = Math.abs(K + ((g - b) / ((6 * chroma) + Number.EPSILON))); 40 | const s = chroma / (r + Number.EPSILON); 41 | const v = r; 42 | 43 | dst[0] = h; 44 | dst[1] = s; 45 | dst[2] = v; 46 | 47 | return dst; 48 | }; 49 | 50 | /** 51 | * Converts an HSV color value to RGB. Conversion formula 52 | * adapted from https://gist.github.com/mjackson/5311256. 53 | * Assumes h, s, and v are contained in the set [0, 1] and 54 | * returns r, g, and b in the set [0, 255]. 55 | * 56 | * @param {Array} hsv The HSV color value 57 | * @param {number} hsv.h The hue 58 | * @param {number} hsv.s The saturation 59 | * @param {number} hsv.v The value 60 | * @param {Uint8Array|Uint8ClampedArray} dst The array to store the RGB values in 61 | * @return {Uint8Array|Uint8ClampedArray} The `dst` array passed in 62 | */ 63 | const hsvToRgb = ([h, s, v], dst) => { 64 | if (s === 0) { 65 | dst[0] = dst[1] = dst[2] = (v * 255) + 0.5; 66 | return dst; 67 | } 68 | 69 | // keep hue in [0,1) so the `switch(i)` below only needs 6 cases (0-5) 70 | h %= 1; 71 | const i = (h * 6) | 0; 72 | const f = (h * 6) - i; 73 | const p = v * (1 - s); 74 | const q = v * (1 - (s * f)); 75 | const t = v * (1 - (s * (1 - f))); 76 | 77 | let r = 0; 78 | let g = 0; 79 | let b = 0; 80 | 81 | switch (i) { 82 | case 0: r = v; g = t; b = p; break; 83 | case 1: r = q; g = v; b = p; break; 84 | case 2: r = p; g = v; b = t; break; 85 | case 3: r = p; g = q; b = v; break; 86 | case 4: r = t; g = p; b = v; break; 87 | case 5: r = v; g = p; b = q; break; 88 | } 89 | 90 | // Add 0.5 in order to round. Setting integer TypedArray elements implicitly floors. 91 | dst[0] = (r * 255) + 0.5; 92 | dst[1] = (g * 255) + 0.5; 93 | dst[2] = (b * 255) + 0.5; 94 | return dst; 95 | }; 96 | 97 | module.exports = {rgbToHsv, hsvToRgb}; 98 | -------------------------------------------------------------------------------- /src/util/log.js: -------------------------------------------------------------------------------- 1 | const minilog = require('minilog'); 2 | minilog.enable(); 3 | 4 | module.exports = minilog('scratch-render'); 5 | -------------------------------------------------------------------------------- /src/util/text-wrapper.js: -------------------------------------------------------------------------------- 1 | const LineBreaker = require('!ify-loader!linebreak'); 2 | const GraphemeBreaker = require('!ify-loader!grapheme-breaker'); 3 | 4 | /** 5 | * Tell this text wrapper to use a specific measurement provider. 6 | * @typedef {object} MeasurementProvider - the new measurement provider. 7 | * @property {Function} beginMeasurementSession - this will be called before a batch of measurements are made. 8 | * Optionally, this function may return an object to be provided to the endMeasurementSession function. 9 | * @property {Function} measureText - this will be called each time a piece of text must be measured. 10 | * @property {Function} endMeasurementSession - this will be called after a batch of measurements is finished. 11 | * It will be passed whatever value beginMeasurementSession returned, if any. 12 | */ 13 | 14 | /** 15 | * Utility to wrap text across several lines, respecting Unicode grapheme clusters and, when possible, Unicode line 16 | * break opportunities. 17 | * Reference material: 18 | * - Unicode Standard Annex #14: http://unicode.org/reports/tr14/ 19 | * - Unicode Standard Annex #29: http://unicode.org/reports/tr29/ 20 | * - "JavaScript has a Unicode problem" by Mathias Bynens: https://mathiasbynens.be/notes/javascript-unicode 21 | */ 22 | class TextWrapper { 23 | /** 24 | * Construct a text wrapper which will measure text using the specified measurement provider. 25 | * @param {MeasurementProvider} measurementProvider - a helper object to provide text measurement services. 26 | */ 27 | constructor (measurementProvider) { 28 | this._measurementProvider = measurementProvider; 29 | this._cache = {}; 30 | } 31 | 32 | /** 33 | * Wrap the provided text into lines restricted to a maximum width. See Unicode Standard Annex (UAX) #14. 34 | * @param {number} maxWidth - the maximum allowed width of a line. 35 | * @param {string} text - the text to be wrapped. Will be split on whitespace. 36 | * @returns {Array.} an array containing the wrapped lines of text. 37 | */ 38 | wrapText (maxWidth, text) { 39 | // Normalize to canonical composition (see Unicode Standard Annex (UAX) #15) 40 | text = text.normalize(); 41 | 42 | const cacheKey = `${maxWidth}-${text}`; 43 | if (this._cache[cacheKey]) { 44 | return this._cache[cacheKey]; 45 | } 46 | 47 | const measurementSession = this._measurementProvider.beginMeasurementSession(); 48 | 49 | const breaker = new LineBreaker(text); 50 | let lastPosition = 0; 51 | let nextBreak; 52 | let currentLine = null; 53 | const lines = []; 54 | 55 | while ((nextBreak = breaker.nextBreak())) { 56 | const word = text.slice(lastPosition, nextBreak.position).replace(/\n+$/, ''); 57 | 58 | let proposedLine = (currentLine || '').concat(word); 59 | let proposedLineWidth = this._measurementProvider.measureText(proposedLine); 60 | 61 | if (proposedLineWidth > maxWidth) { 62 | // The next word won't fit on this line. Will it fit on a line by itself? 63 | const wordWidth = this._measurementProvider.measureText(word); 64 | if (wordWidth > maxWidth) { 65 | // The next word can't even fit on a line by itself. Consume it one grapheme cluster at a time. 66 | let lastCluster = 0; 67 | let nextCluster; 68 | while (lastCluster !== (nextCluster = GraphemeBreaker.nextBreak(word, lastCluster))) { 69 | const cluster = word.substring(lastCluster, nextCluster); 70 | proposedLine = (currentLine || '').concat(cluster); 71 | proposedLineWidth = this._measurementProvider.measureText(proposedLine); 72 | if ((currentLine === null) || (proposedLineWidth <= maxWidth)) { 73 | // first cluster of a new line or the cluster fits 74 | currentLine = proposedLine; 75 | } else { 76 | // no more can fit 77 | lines.push(currentLine); 78 | currentLine = cluster; 79 | } 80 | lastCluster = nextCluster; 81 | } 82 | } else { 83 | // The next word can fit on the next line. Finish the current line and move on. 84 | if (currentLine !== null) lines.push(currentLine); 85 | currentLine = word; 86 | } 87 | } else { 88 | // The next word fits on this line. Just keep going. 89 | currentLine = proposedLine; 90 | } 91 | 92 | // Did we find a \n or similar? 93 | if (nextBreak.required) { 94 | if (currentLine !== null) lines.push(currentLine); 95 | currentLine = null; 96 | } 97 | 98 | lastPosition = nextBreak.position; 99 | } 100 | 101 | currentLine = currentLine || ''; 102 | if (currentLine.length > 0 || lines.length === 0) { 103 | lines.push(currentLine); 104 | } 105 | 106 | this._cache[cacheKey] = lines; 107 | this._measurementProvider.endMeasurementSession(measurementSession); 108 | return lines; 109 | } 110 | } 111 | 112 | module.exports = TextWrapper; 113 | -------------------------------------------------------------------------------- /test/fixtures/MockSkin.js: -------------------------------------------------------------------------------- 1 | const Skin = require('../../src/Skin'); 2 | 3 | class MockSkin extends Skin { 4 | set size (dimensions) { 5 | this.dimensions = dimensions; 6 | } 7 | 8 | get size () { 9 | return this.dimensions || [0, 0]; 10 | } 11 | 12 | set rotationCenter (center) { 13 | this._rotationCenter[0] = center[0]; 14 | this._rotationCenter[1] = center[1]; 15 | this.emit(Skin.Events.WasAltered); 16 | } 17 | 18 | get rotationCenter () { 19 | return this._rotationCenter; 20 | } 21 | } 22 | 23 | module.exports = MockSkin; 24 | -------------------------------------------------------------------------------- /test/helper/page-util.js: -------------------------------------------------------------------------------- 1 | /* global VirtualMachine, ScratchStorage, ScratchSVGRenderer */ 2 | /* eslint-env browser */ 3 | 4 | // Wait for all SVG skins to be loaded. 5 | // TODO: this is extremely janky and should be removed once vm.loadProject waits for SVG skins to load 6 | // https://github.com/LLK/scratch-render/issues/563 7 | window.waitForSVGSkinLoad = renderer => new Promise(resolve => { 8 | // eslint-disable-next-line prefer-const 9 | let interval; 10 | 11 | const waitInner = () => { 12 | let numSVGSkins = 0; 13 | let numLoadedSVGSkins = 0; 14 | for (const skin of renderer._allSkins) { 15 | if (skin.constructor.name !== 'SVGSkin') continue; 16 | numSVGSkins++; 17 | if (skin._svgImage.complete) numLoadedSVGSkins++; 18 | } 19 | 20 | if (numSVGSkins === numLoadedSVGSkins) { 21 | clearInterval(interval); 22 | resolve(); 23 | } 24 | }; 25 | 26 | interval = setInterval(waitInner, 1); 27 | }); 28 | 29 | window.loadFileInputIntoVM = (fileInput, vm, render) => { 30 | const reader = new FileReader(); 31 | return new Promise(resolve => { 32 | reader.onload = () => { 33 | vm.start(); 34 | vm.loadProject(reader.result) 35 | .then(() => window.waitForSVGSkinLoad(render)) 36 | .then(() => { 37 | resolve(); 38 | }); 39 | }; 40 | reader.readAsArrayBuffer(fileInput.files[0]); 41 | }); 42 | }; 43 | 44 | window.initVM = render => { 45 | const vm = new VirtualMachine(); 46 | const storage = new ScratchStorage.ScratchStorage(); 47 | 48 | vm.attachStorage(storage); 49 | vm.attachRenderer(render); 50 | vm.attachV2SVGAdapter(ScratchSVGRenderer.V2SVGAdapter); 51 | vm.attachV2BitmapAdapter(new ScratchSVGRenderer.BitmapAdapter()); 52 | 53 | return vm; 54 | }; 55 | -------------------------------------------------------------------------------- /test/integration/cpu-render.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 69 | 70 | -------------------------------------------------------------------------------- /test/integration/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 28 | 29 | -------------------------------------------------------------------------------- /test/integration/pick-tests.js: -------------------------------------------------------------------------------- 1 | /* global vm, render */ 2 | const {chromium} = require('playwright-chromium'); 3 | const test = require('tap').test; 4 | const path = require('path'); 5 | 6 | const indexHTML = path.resolve(__dirname, 'index.html'); 7 | const testDir = (...args) => path.resolve(__dirname, 'pick-tests', ...args); 8 | 9 | const runFile = async (file, action, page, script) => { 10 | // start each test by going to the index.html, and loading the scratch file 11 | await page.goto(`file://${indexHTML}`); 12 | const fileInput = await page.$('#file'); 13 | await fileInput.setInputFiles(testDir(file)); 14 | 15 | await page.evaluate(() => 16 | // `loadFile` is defined on the page itself. 17 | // eslint-disable-next-line no-undef 18 | loadFile() 19 | ); 20 | return page.evaluate(`(function () {return (${script})(${action});})()`); 21 | }; 22 | 23 | // immediately invoked async function to let us wait for each test to finish before starting the next. 24 | (async () => { 25 | const browser = await chromium.launch(); 26 | const page = await browser.newPage(); 27 | 28 | const testOperation = async function (name, action, expect) { 29 | await test(name, async t => { 30 | 31 | const results = await runFile('test-mouse-touch.sb2', action, page, boundAction => { 32 | vm.greenFlag(); 33 | const sendResults = []; 34 | 35 | const idToTargetName = id => { 36 | const target = vm.runtime.targets.find(tar => tar.drawableID === id); 37 | if (!target) { 38 | return `[Unknown drawableID: ${id}]`; 39 | } 40 | return target.sprite.name; 41 | }; 42 | const sprite = vm.runtime.targets.find(target => target.sprite.name === 'Sprite1'); 43 | 44 | boundAction({ 45 | sendResults, 46 | idToTargetName, 47 | render, 48 | sprite 49 | }); 50 | return sendResults; 51 | }); 52 | 53 | t.plan(expect.length); 54 | for (let x = 0; x < expect.length; x++) { 55 | t.deepEqual(results[x], expect[x], expect[x][0]); 56 | } 57 | t.end(); 58 | }); 59 | }; 60 | 61 | const tests = [ 62 | { 63 | name: 'pick Sprite1', 64 | action: ({sendResults, render, idToTargetName}) => { 65 | sendResults.push(['center', idToTargetName(render.pick(360, 180))]); 66 | }, 67 | expect: [['center', 'Sprite1']] 68 | }, 69 | { 70 | name: 'pick Stage', 71 | action: ({sendResults, render, idToTargetName}) => { 72 | sendResults.push(['left', idToTargetName(render.pick(320, 180))]); 73 | }, 74 | expect: [['left', 'Stage']] 75 | }, 76 | { 77 | name: 'touching Sprite1', 78 | action: ({sprite, sendResults, render}) => { 79 | sendResults.push(['over', render.drawableTouching(sprite.drawableID, 360, 180)]); 80 | }, 81 | expect: [['over', true]] 82 | }, 83 | { 84 | name: 'pick Stage through hidden Sprite1', 85 | action: ({sprite, sendResults, render, idToTargetName}) => { 86 | sprite.setVisible(false); 87 | sendResults.push(['hidden sprite pick center', idToTargetName(render.pick(360, 180))]); 88 | }, 89 | expect: [['hidden sprite pick center', 'Stage']] 90 | }, 91 | { 92 | name: 'touching hidden Sprite1', 93 | action: ({sprite, sendResults, render}) => { 94 | sprite.setVisible(false); 95 | sendResults.push(['hidden over', render.drawableTouching(sprite.drawableID, 360, 180)]); 96 | }, 97 | expect: [['hidden over', true]] 98 | } 99 | ]; 100 | for (const {name, action, expect} of tests) { 101 | await testOperation(name, action, expect); 102 | } 103 | 104 | // close the browser window we used 105 | await browser.close(); 106 | })().catch(err => { 107 | // Handle promise rejections by exiting with a nonzero code to ensure that tests don't erroneously pass 108 | // eslint-disable-next-line no-console 109 | console.error(err.message); 110 | process.exit(1); 111 | }); 112 | -------------------------------------------------------------------------------- /test/integration/pick-tests/test-mouse-touch.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/pick-tests/test-mouse-touch.sb2 -------------------------------------------------------------------------------- /test/integration/scratch-tests.js: -------------------------------------------------------------------------------- 1 | /* global vm */ 2 | const {chromium} = require('playwright-chromium'); 3 | const test = require('tap').test; 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | const allGpuModes = ['ForceCPU', 'ForceGPU', 'Automatic']; 8 | 9 | const indexHTML = path.resolve(__dirname, 'index.html'); 10 | const testDir = (...args) => path.resolve(__dirname, 'scratch-tests', ...args); 11 | 12 | const checkOneGpuMode = (t, says) => { 13 | // Map string messages to tap reporting methods. This will be used 14 | // with events from scratch's runtime emitted on block instructions. 15 | let didPlan = false; 16 | let didEnd = false; 17 | const reporters = { 18 | comment (message) { 19 | t.comment(message); 20 | }, 21 | pass (reason) { 22 | t.pass(reason); 23 | }, 24 | fail (reason) { 25 | t.fail(reason); 26 | }, 27 | plan (count) { 28 | didPlan = true; 29 | t.plan(Number(count)); 30 | }, 31 | end () { 32 | didEnd = true; 33 | t.end(); 34 | } 35 | }; 36 | 37 | // loop over each "SAY" we caught from the VM and use the reporters 38 | says.forEach(text => { 39 | // first word of the say is going to be a "command" 40 | const command = text.split(/\s+/, 1)[0].toLowerCase(); 41 | if (reporters[command]) { 42 | return reporters[command](text.substring(command.length).trim()); 43 | } 44 | 45 | // Default to a comment with the full text if we didn't match 46 | // any command prefix 47 | return reporters.comment(text); 48 | }); 49 | 50 | if (!didPlan) { 51 | t.comment('did not say "plan NUMBER_OF_TESTS"'); 52 | } 53 | 54 | // End must be called so that tap knows the test is done. If 55 | // the test has a SAY "end" block but that block did not 56 | // execute, this explicit failure will raise that issue so 57 | // it can be resolved. 58 | if (!didEnd) { 59 | t.fail('did not say "end"'); 60 | t.end(); 61 | } 62 | }; 63 | 64 | const testFile = async (file, page) => { 65 | // start each test by going to the index.html, and loading the scratch file 66 | await page.goto(`file://${indexHTML}`); 67 | const fileInput = await page.$('#file'); 68 | await fileInput.setInputFiles(testDir(file)); 69 | await page.evaluate(() => 70 | // `loadFile` is defined on the page itself. 71 | // eslint-disable-next-line no-undef 72 | loadFile() 73 | ); 74 | const says = await page.evaluate(async useGpuModes => { 75 | // This function is run INSIDE the integration chrome browser via some 76 | // injection and .toString() magic. We can return some "simple data" 77 | // back across as a promise, so we will just log all the says that happen 78 | // for parsing after. 79 | 80 | // this becomes the `says` in the outer scope 81 | const allMessages = {}; 82 | const TIMEOUT = 5000; 83 | 84 | vm.runtime.on('SAY', (_, __, message) => { 85 | const messages = allMessages[vm.renderer._useGpuMode]; 86 | messages.push(message); 87 | }); 88 | 89 | for (const useGpuMode of useGpuModes) { 90 | const messages = allMessages[useGpuMode] = []; 91 | 92 | vm.renderer.setUseGpuMode(useGpuMode); 93 | vm.greenFlag(); 94 | const startTime = Date.now(); 95 | 96 | // wait for all threads to complete before moving on to the next mode 97 | while (vm.runtime.threads.some(thread => vm.runtime.isActiveThread(thread))) { 98 | if ((Date.now() - startTime) >= TIMEOUT) { 99 | // if we push the message after end, the failure from tap is not very useful: 100 | // "not ok test after end() was called" 101 | messages.unshift(`fail Threads still running after ${TIMEOUT}ms`); 102 | break; 103 | } 104 | 105 | await new Promise(resolve => setTimeout(resolve, 50)); 106 | } 107 | } 108 | 109 | return allMessages; 110 | }, allGpuModes); 111 | 112 | for (const gpuMode of allGpuModes) { 113 | test(`File: ${file}, GPU Mode: ${gpuMode}`, t => checkOneGpuMode(t, says[gpuMode])); 114 | } 115 | }; 116 | 117 | // immediately invoked async function to let us wait for each test to finish before starting the next. 118 | (async () => { 119 | const browser = await chromium.launch(); 120 | const page = await browser.newPage(); 121 | 122 | const files = fs.readdirSync(testDir()) 123 | .filter(uri => uri.endsWith('.sb2') || uri.endsWith('.sb3')); 124 | 125 | for (const file of files) { 126 | await testFile(file, page); 127 | } 128 | 129 | // close the browser window we used 130 | await browser.close(); 131 | })().catch(err => { 132 | // Handle promise rejections by exiting with a nonzero code to ensure that tests don't erroneously pass 133 | // eslint-disable-next-line no-console 134 | console.error(err.message); 135 | process.exit(1); 136 | }); 137 | -------------------------------------------------------------------------------- /test/integration/scratch-tests/cat-touches-box.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/cat-touches-box.sb2 -------------------------------------------------------------------------------- /test/integration/scratch-tests/cat-touches-pen.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/cat-touches-pen.sb2 -------------------------------------------------------------------------------- /test/integration/scratch-tests/clear-color.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/clear-color.sb3 -------------------------------------------------------------------------------- /test/integration/scratch-tests/color-touching-tests.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/color-touching-tests.sb2 -------------------------------------------------------------------------------- /test/integration/scratch-tests/disappearing-pen.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/disappearing-pen.sb3 -------------------------------------------------------------------------------- /test/integration/scratch-tests/doesnt-touch-say-bubble.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/doesnt-touch-say-bubble.sb2 -------------------------------------------------------------------------------- /test/integration/scratch-tests/fencing-bounds.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/fencing-bounds.sb3 -------------------------------------------------------------------------------- /test/integration/scratch-tests/ghost-hidden-collide.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/ghost-hidden-collide.sb2 -------------------------------------------------------------------------------- /test/integration/scratch-tests/pixelate-touching.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/pixelate-touching.sb3 -------------------------------------------------------------------------------- /test/integration/scratch-tests/sprite-goes-off-stage.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/sprite-goes-off-stage.sb2 -------------------------------------------------------------------------------- /test/integration/scratch-tests/tippy-toe-collision.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-render/062e8e694aa0f6579413dc7d7bf133b0e8e3a663/test/integration/scratch-tests/tippy-toe-collision.sb2 -------------------------------------------------------------------------------- /test/integration/skin-size-tests.js: -------------------------------------------------------------------------------- 1 | /* global render, ImageData */ 2 | const {chromium} = require('playwright-chromium'); 3 | const test = require('tap').test; 4 | const path = require('path'); 5 | 6 | const indexHTML = path.resolve(__dirname, 'index.html'); 7 | 8 | // immediately invoked async function to let us wait for each test to finish before starting the next. 9 | (async () => { 10 | const browser = await chromium.launch(); 11 | const page = await browser.newPage(); 12 | 13 | await page.goto(`file://${indexHTML}`); 14 | 15 | await test('SVG skin size set properly', async t => { 16 | t.plan(1); 17 | const skinSize = await page.evaluate(() => { 18 | const skinID = render.createSVGSkin(``); 19 | return render.getSkinSize(skinID); 20 | }); 21 | t.same(skinSize, [50, 100]); 22 | }); 23 | 24 | await test('Bitmap skin size set correctly', async t => { 25 | t.plan(1); 26 | const skinSize = await page.evaluate(() => { 27 | // Bitmap costumes are double resolution, so double the ImageData size 28 | const skinID = render.createBitmapSkin(new ImageData(100, 200), 2); 29 | return render.getSkinSize(skinID); 30 | }); 31 | t.same(skinSize, [50, 100]); 32 | }); 33 | 34 | await test('Pen skin size set correctly', async t => { 35 | t.plan(1); 36 | const skinSize = await page.evaluate(() => { 37 | const skinID = render.createPenSkin(); 38 | return render.getSkinSize(skinID); 39 | }); 40 | const nativeSize = await page.evaluate(() => render.getNativeSize()); 41 | t.same(skinSize, nativeSize); 42 | }); 43 | 44 | await test('Text bubble skin size set correctly', async t => { 45 | t.plan(1); 46 | const skinSize = await page.evaluate(() => { 47 | const skinID = render.createTextSkin('say', 'Hello', false); 48 | return render.getSkinSize(skinID); 49 | }); 50 | // The subtleties in font rendering may cause the size of the text bubble to vary, so just make sure it's not 0 51 | t.notSame(skinSize, [0, 0]); 52 | }); 53 | 54 | // close the browser window we used 55 | await browser.close(); 56 | })().catch(err => { 57 | // Handle promise rejections by exiting with a nonzero code to ensure that tests don't erroneously pass 58 | // eslint-disable-next-line no-console 59 | console.error(err.message); 60 | process.exit(1); 61 | }); 62 | -------------------------------------------------------------------------------- /test/unit/ColorConversionTests.js: -------------------------------------------------------------------------------- 1 | const {test, Test} = require('tap'); 2 | 3 | const {rgbToHsv, hsvToRgb} = require('../../src/util/color-conversions'); 4 | 5 | Test.prototype.addAssert('colorsAlmostEqual', 2, function (found, wanted, message, extra) { 6 | /* eslint-disable no-invalid-this */ 7 | message += `: found ${JSON.stringify(Array.from(found))}, wanted ${JSON.stringify(Array.from(wanted))}`; 8 | 9 | // should always return another assert call, or 10 | // this.pass(message) or this.fail(message, extra) 11 | if (found.length !== wanted.length) { 12 | return this.fail(message, extra); 13 | } 14 | 15 | for (let i = 0; i < found.length; i++) { 16 | // smallest meaningful difference--detects changes in hue value after rounding 17 | if (Math.abs(found[i] - wanted[i]) >= 0.5 / 360) { 18 | return this.fail(message, extra); 19 | } 20 | } 21 | 22 | return this.pass(message); 23 | /* eslint-enable no-invalid-this */ 24 | }); 25 | 26 | test('RGB to HSV', t => { 27 | const dst = [0, 0, 0]; 28 | t.colorsAlmostEqual(rgbToHsv([255, 255, 255], dst), [0, 0, 1], 'white'); 29 | t.colorsAlmostEqual(rgbToHsv([0, 0, 0], dst), [0, 0, 0], 'black'); 30 | t.colorsAlmostEqual(rgbToHsv([127, 127, 127], dst), [0, 0, 0.498], 'grey'); 31 | t.colorsAlmostEqual(rgbToHsv([255, 255, 0], dst), [0.167, 1, 1], 'yellow'); 32 | t.colorsAlmostEqual(rgbToHsv([1, 0, 0], dst), [0, 1, 0.00392], 'dark red'); 33 | 34 | t.end(); 35 | }); 36 | 37 | test('HSV to RGB', t => { 38 | const dst = new Uint8ClampedArray(3); 39 | t.colorsAlmostEqual(hsvToRgb([0, 1, 1], dst), [255, 0, 0], 'red'); 40 | t.colorsAlmostEqual(hsvToRgb([1, 1, 1], dst), [255, 0, 0], 'red (hue of 1)'); 41 | t.colorsAlmostEqual(hsvToRgb([0.5, 1, 1], dst), [0, 255, 255], 'cyan'); 42 | t.colorsAlmostEqual(hsvToRgb([1.5, 1, 1], dst), [0, 255, 255], 'cyan (hue of 1.5)'); 43 | t.colorsAlmostEqual(hsvToRgb([0, 0, 0], dst), [0, 0, 0], 'black'); 44 | t.colorsAlmostEqual(hsvToRgb([0.5, 1, 0], dst), [0, 0, 0], 'black (with hue and saturation)'); 45 | t.colorsAlmostEqual(hsvToRgb([0, 1, 0.00392], dst), [1, 0, 0], 'dark red'); 46 | 47 | t.end(); 48 | }); 49 | -------------------------------------------------------------------------------- /test/unit/DrawableTests.js: -------------------------------------------------------------------------------- 1 | const test = require('tap').test; 2 | 3 | // Mock `window` and `document.createElement` for twgl.js. 4 | global.window = {}; 5 | global.document = { 6 | createElement: () => ({getContext: () => {}}) 7 | }; 8 | 9 | const Drawable = require('../../src/Drawable'); 10 | const MockSkin = require('../fixtures/MockSkin'); 11 | const Rectangle = require('../../src/Rectangle'); 12 | 13 | /** 14 | * Returns a Rectangle-like object, with dimensions rounded to the given number 15 | * of digits. 16 | * @param {Rectangle} rect The source rectangle. 17 | * @param {int} decimals The number of decimal points to snap to. 18 | * @returns {object} An object with left/right/top/bottom attributes. 19 | */ 20 | const snapToNearest = function (rect, decimals = 3) { 21 | return { 22 | left: rect.left.toFixed(decimals), 23 | right: rect.right.toFixed(decimals), 24 | bottom: rect.bottom.toFixed(decimals), 25 | top: rect.top.toFixed(decimals) 26 | }; 27 | }; 28 | 29 | test('translate by position', t => { 30 | const expected = new Rectangle(); 31 | const drawable = new Drawable(); 32 | drawable.skin = new MockSkin(); 33 | drawable.skin.size = [200, 50]; 34 | 35 | expected.initFromBounds(0, 200, -50, 0); 36 | t.same(snapToNearest(drawable.getAABB()), expected); 37 | 38 | drawable.updateProperties({position: [1, 2]}); 39 | expected.initFromBounds(1, 201, -48, 2); 40 | t.same(snapToNearest(drawable.getAABB()), expected); 41 | 42 | t.end(); 43 | }); 44 | 45 | test('translate by costume center', t => { 46 | const expected = new Rectangle(); 47 | const drawable = new Drawable(); 48 | drawable.skin = new MockSkin(); 49 | drawable.skin.size = [200, 50]; 50 | 51 | drawable.skin.rotationCenter = [1, 0]; 52 | expected.initFromBounds(-1, 199, -50, 0); 53 | t.same(snapToNearest(drawable.getAABB()), expected); 54 | 55 | drawable.skin.rotationCenter = [0, -2]; 56 | expected.initFromBounds(0, 200, -52, -2); 57 | t.same(snapToNearest(drawable.getAABB()), expected); 58 | 59 | t.end(); 60 | }); 61 | 62 | test('translate and rotate', t => { 63 | const expected = new Rectangle(); 64 | const drawable = new Drawable(); 65 | drawable.skin = new MockSkin(); 66 | drawable.skin.size = [200, 50]; 67 | 68 | drawable.updateProperties({position: [1, 2], direction: 0}); 69 | expected.initFromBounds(1, 51, 2, 202); 70 | t.same(snapToNearest(drawable.getAABB()), expected); 71 | 72 | drawable.updateProperties({direction: 180}); 73 | expected.initFromBounds(-49, 1, -198, 2); 74 | t.same(snapToNearest(drawable.getAABB()), expected); 75 | 76 | drawable.skin.rotationCenter = [100, 25]; 77 | drawable.updateProperties({direction: 270, position: [0, 0]}); 78 | expected.initFromBounds(-100, 100, -25, 25); 79 | t.same(snapToNearest(drawable.getAABB()), expected); 80 | 81 | drawable.updateProperties({direction: 90}); 82 | t.same(snapToNearest(drawable.getAABB()), expected); 83 | 84 | t.end(); 85 | }); 86 | 87 | test('rotate by non-right-angles', t => { 88 | const expected = new Rectangle(); 89 | const drawable = new Drawable(); 90 | drawable.skin = new MockSkin(); 91 | drawable.skin.size = [10, 10]; 92 | drawable.skin.rotationCenter = [5, 5]; 93 | 94 | expected.initFromBounds(-5, 5, -5, 5); 95 | t.same(snapToNearest(drawable.getAABB()), expected); 96 | 97 | drawable.updateProperties({direction: 45}); 98 | expected.initFromBounds(-7.071, 7.071, -7.071, 7.071); 99 | t.same(snapToNearest(drawable.getAABB()), expected); 100 | 101 | t.end(); 102 | }); 103 | 104 | test('scale', t => { 105 | const expected = new Rectangle(); 106 | const drawable = new Drawable(); 107 | drawable.skin = new MockSkin(); 108 | drawable.skin.size = [200, 50]; 109 | 110 | drawable.updateProperties({scale: [100, 50]}); 111 | expected.initFromBounds(0, 200, -25, 0); 112 | t.same(snapToNearest(drawable.getAABB()), expected); 113 | 114 | drawable.skin.rotationCenter = [0, 25]; 115 | expected.initFromBounds(0, 200, -12.5, 12.5); 116 | t.same(snapToNearest(drawable.getAABB()), expected); 117 | 118 | drawable.skin.rotationCenter = [150, 50]; 119 | drawable.updateProperties({scale: [50, 50]}); 120 | expected.initFromBounds(-75, 25, 0, 25); 121 | t.same(snapToNearest(drawable.getAABB()), expected); 122 | 123 | t.end(); 124 | }); 125 | 126 | test('rotate and scale', t => { 127 | const expected = new Rectangle(); 128 | const drawable = new Drawable(); 129 | drawable.skin = new MockSkin(); 130 | drawable.skin.size = [100, 1000]; 131 | 132 | drawable.skin.rotationCenter = [50, 50]; 133 | expected.initFromBounds(-50, 50, -950, 50); 134 | t.same(snapToNearest(drawable.getAABB()), expected); 135 | 136 | drawable.updateProperties({scale: [40, 60]}); 137 | drawable.skin.rotationCenter = [50, 50]; 138 | expected.initFromBounds(-20, 20, -570, 30); 139 | t.same(snapToNearest(drawable.getAABB()), expected); 140 | 141 | t.end(); 142 | }); 143 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | 5 | const ScratchWebpackConfigBuilder = require('scratch-webpack-configuration'); 6 | 7 | const baseConfig = new ScratchWebpackConfigBuilder({ 8 | rootPath: path.resolve(__dirname) 9 | }) 10 | .enableDevServer(process.env.PORT || 8361) 11 | .merge({ 12 | resolve: { 13 | fallback: { 14 | Buffer: require.resolve('buffer/') 15 | } 16 | } 17 | }); 18 | 19 | const webConfig = baseConfig.clone() 20 | .setTarget('browserslist') 21 | .merge({ 22 | entry: { 23 | 'scratch-render': path.join(__dirname, 'src/index.js'), 24 | 'scratch-render.min': path.join(__dirname, 'src/index.js') 25 | }, 26 | output: { 27 | library: { 28 | name: 'ScratchRender' 29 | } 30 | } 31 | }); 32 | 33 | const playgroundConfig = baseConfig.clone() 34 | .setTarget('browserslist') 35 | .merge({ 36 | entry: { 37 | playground: path.join(__dirname, 'src/playground/playground.js'), 38 | queryPlayground: path.join(__dirname, 'src/playground/queryPlayground.js') 39 | }, 40 | output: { 41 | path: path.resolve('playground') 42 | } 43 | }) 44 | .addPlugin(new CopyWebpackPlugin([ 45 | { 46 | context: 'src/playground', 47 | from: '*.+(html|css)' 48 | } 49 | ])); 50 | 51 | const nodeConfig = baseConfig.clone() 52 | .setTarget('node') 53 | .merge({ 54 | entry: { 55 | 'scratch-render': path.join(__dirname, 'src/index.js') 56 | }, 57 | output: { 58 | library: { 59 | name: 'ScratchRender', 60 | type: 'commonjs2' 61 | } 62 | }, 63 | externals: { 64 | '!ify-loader!grapheme-breaker': 'grapheme-breaker', 65 | '!ify-loader!linebreak': 'linebreak', 66 | 'hull.js': true, 67 | 'scratch-svg-renderer': true, 68 | 'twgl.js': true, 69 | 'xml-escape': true 70 | } 71 | }); 72 | 73 | module.exports = [ 74 | // Playground 75 | playgroundConfig.get(), 76 | 77 | // Web-compatible 78 | webConfig.get(), 79 | 80 | // Node-compatible 81 | nodeConfig.get() 82 | ]; 83 | --------------------------------------------------------------------------------