Initial commit of 001code-html Scratch frontend project.
Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
14
scratch-render/.editorconfig
Normal file
14
scratch-render/.editorconfig
Normal file
@@ -0,0 +1,14 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{js,jsx,html}]
|
||||
indent_style = space
|
||||
|
||||
[*.{frag,vert}]
|
||||
indent_style = tab
|
||||
4
scratch-render/.eslintignore
Normal file
4
scratch-render/.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/*
|
||||
node_modules/*
|
||||
playground/*
|
||||
tap-snapshots/*
|
||||
4
scratch-render/.eslintrc.js
Normal file
4
scratch-render/.eslintrc.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['scratch', 'scratch/node', 'scratch/es6']
|
||||
};
|
||||
34
scratch-render/.gitattributes
vendored
Normal file
34
scratch-render/.gitattributes
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text=auto
|
||||
|
||||
# Explicitly specify line endings for as many files as possible.
|
||||
# People who (for example) rsync between Windows and Linux need this.
|
||||
|
||||
# File types which we know are binary
|
||||
|
||||
# Prefer LF for most file types
|
||||
*.frag text eol=lf
|
||||
*.htm text eol=lf
|
||||
*.html text eol=lf
|
||||
*.iml text eol=lf
|
||||
*.js text eol=lf
|
||||
*.js.map text eol=lf
|
||||
*.json text eol=lf
|
||||
*.json5 text eol=lf
|
||||
*.md text eol=lf
|
||||
*.vert text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.yml text eol=lf
|
||||
|
||||
# Prefer LF for these files
|
||||
.editorconfig text eol=lf
|
||||
.eslintignore text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.gitmodules text eol=lf
|
||||
LICENSE text eol=lf
|
||||
Makefile text eol=lf
|
||||
README text eol=lf
|
||||
TRADEMARK text eol=lf
|
||||
|
||||
# Use CRLF for Windows-specific file types
|
||||
15
scratch-render/.github/ISSUE_TEMPLATE.md
vendored
Normal file
15
scratch-render/.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
### Expected Behavior
|
||||
|
||||
_Please describe what should happen_
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
_Describe what actually happens_
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
_Explain what someone needs to do in order to see what's described in *Actual behavior* above_
|
||||
|
||||
### Operating System and Browser
|
||||
|
||||
_e.g. Mac OS 10.11.6 Safari 10.0_
|
||||
15
scratch-render/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
15
scratch-render/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
### Resolves
|
||||
|
||||
_What Github issue does this resolve (please include link)?_
|
||||
|
||||
### Proposed Changes
|
||||
|
||||
_Describe what this Pull Request does_
|
||||
|
||||
### Reason for Changes
|
||||
|
||||
_Explain why these changes should be made_
|
||||
|
||||
### Test Coverage
|
||||
|
||||
_Please show how you have added tests to cover your changes_
|
||||
48
scratch-render/.github/workflows/deploy.yml
vendored
Normal file
48
scratch-render/.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Deploy playground
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [develop]
|
||||
|
||||
concurrency:
|
||||
group: "deploy"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build playground
|
||||
run: npm run build
|
||||
- name: Build docs
|
||||
run: npm run docs
|
||||
# We expect to see syntax errors from the old jsdoc cli not understanding some of our syntax
|
||||
# It will still generate what it can, so it's safe to ignore the error
|
||||
continue-on-error: true
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./playground/
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
20
scratch-render/.github/workflows/node.js.yml
vendored
Normal file
20
scratch-render/.github/workflows/node.js.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run build
|
||||
- run: npm run test
|
||||
20
scratch-render/.gitignore
vendored
Normal file
20
scratch-render/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Mac OS
|
||||
.DS_Store
|
||||
|
||||
# NPM
|
||||
/node_modules
|
||||
npm-*
|
||||
|
||||
# Testing
|
||||
/.nyc_output
|
||||
/coverage
|
||||
|
||||
# IDEA
|
||||
/.idea
|
||||
|
||||
# Build
|
||||
/dist
|
||||
/playground
|
||||
|
||||
# Act
|
||||
.secrets
|
||||
21
scratch-render/.jsdoc.json
Normal file
21
scratch-render/.jsdoc.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"plugins": ["plugins/markdown"],
|
||||
"templates": {
|
||||
"default": {
|
||||
"includeDate": false,
|
||||
"outputSourceFiles": false
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"include": ["src"]
|
||||
},
|
||||
"opts": {
|
||||
"destination": "playground/docs",
|
||||
"pedantic": true,
|
||||
"private": true,
|
||||
"readme": "README.md",
|
||||
"recurse": true,
|
||||
"template": "node_modules/docdash",
|
||||
"tutorials": "docs"
|
||||
}
|
||||
}
|
||||
16
scratch-render/.npmignore
Normal file
16
scratch-render/.npmignore
Normal file
@@ -0,0 +1,16 @@
|
||||
# Development files
|
||||
.eslintrc.js
|
||||
/.editorconfig
|
||||
/.eslintignore
|
||||
/.gitattributes
|
||||
/.github
|
||||
/.jsdoc.json
|
||||
/.travis.yml
|
||||
/test
|
||||
/webpack.config.js
|
||||
|
||||
# Build created files
|
||||
/playground
|
||||
|
||||
# Exclude already built packages from testing with npm pack
|
||||
/scratch-render-*.{tar,tgz}
|
||||
1
scratch-render/.nvmrc
Normal file
1
scratch-render/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
v16
|
||||
373
scratch-render/LICENSE
Normal file
373
scratch-render/LICENSE
Normal file
@@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
118
scratch-render/README.md
Normal file
118
scratch-render/README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# TurboWarp/scratch-render
|
||||
|
||||
scratch-render modified for use in [TurboWarp](https://turbowarp.org/). We've optimized some operations and added a lot of options.
|
||||
|
||||
## Setup
|
||||
|
||||
See https://github.com/TurboWarp/scratch-gui/wiki/Getting-Started to setup the complete TurboWarp environment.
|
||||
|
||||
If you just want to play with the render then it's the same process as upstream scratch-render.
|
||||
|
||||
## API
|
||||
|
||||
Public APIs are compatible with a vanilla scratch-render. TurboWarp/scratch-render is a drop-in replacement for scratch-render.
|
||||
|
||||
Notable public API additions include:
|
||||
|
||||
- `renderer.setUseHighQualityRender(enabled: boolean)` toggles high quality rendering. A `UseHighQualityRenderChanged` event is emitted on the renderer when this is called. You can read the current setting with `renderer.useHighQualityRender` but don't try to directly modify this value.
|
||||
- `renderer.markSkinAsPrivate(skinID: number)` marks a skin as "private".
|
||||
- `renderer.allowPrivateSkinAccess` controls whether blocks like "touching color" can access "private" skins.
|
||||
- `renderer.offscreenTouching` controls whether collision blocks work offscreen.
|
||||
- Skins no longer extend EventEmitter
|
||||
- `RenderWebGL.powerPreference` can be set to change the WebGL powerPreference option for future RenderWebGL instances. (default, high-performance, or low-power)
|
||||
|
||||
## License
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
This program is based on [scratchfoundation/scratch-vm](https://github.com/scratchfoundation/scratch-vm), which is under this license:
|
||||
|
||||
```
|
||||
Copyright (c) 2016, Massachusetts Institute of Technology
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
|
||||
<!--
|
||||
|
||||
## scratch-render
|
||||
#### WebGL-based rendering engine for Scratch 3.0
|
||||
|
||||
[](https://circleci.com/gh/LLK/scratch-render?branch=develop)
|
||||
|
||||
[](https://greenkeeper.io/)
|
||||
|
||||
## Installation
|
||||
```bash
|
||||
npm install https://github.com/LLK/scratch-render.git
|
||||
```
|
||||
|
||||
## Setup
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Scratch WebGL rendering demo</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<canvas id="myStage"></canvas>
|
||||
<canvas id="myDebug"></canvas>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
```js
|
||||
var canvas = document.getElementById('myStage');
|
||||
var debug = document.getElementById('myDebug');
|
||||
|
||||
// Instantiate the renderer
|
||||
var renderer = new require('scratch-render')(canvas);
|
||||
|
||||
// Connect to debug canvas
|
||||
renderer.setDebugCanvas(debug);
|
||||
|
||||
// Start drawing
|
||||
function drawStep() {
|
||||
renderer.draw();
|
||||
requestAnimationFrame(drawStep);
|
||||
}
|
||||
drawStep();
|
||||
|
||||
// Connect to worker (see "playground" example)
|
||||
var worker = new Worker('worker.js');
|
||||
renderer.connectWorker(worker);
|
||||
```
|
||||
|
||||
## Standalone Build
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
```html
|
||||
<script src="/path/to/render.js"></script>
|
||||
<script>
|
||||
var renderer = new window.RenderWebGLLocal();
|
||||
// do things
|
||||
</script>
|
||||
```
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
## Donate
|
||||
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!
|
||||
|
||||
-->
|
||||
1
scratch-render/TRADEMARK
Normal file
1
scratch-render/TRADEMARK
Normal file
@@ -0,0 +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.
|
||||
192
scratch-render/docs/Rectangle-AABB-Matrix.md
Normal file
192
scratch-render/docs/Rectangle-AABB-Matrix.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Rectangle AABB Matrix
|
||||
|
||||
Initialize a Rectangle to a 1 unit square centered at 0 x 0 transformed by a model matrix.
|
||||
|
||||
-----
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
We can start from this approach and determine an alternative one that prodcues the same output with less work.
|
||||
|
||||
Starting with transforming one point, here is a 3D point, `v`, transformation by a matrix, `m`.
|
||||
|
||||
```js
|
||||
const v0 = v[0];
|
||||
const v1 = v[1];
|
||||
const v2 = v[2];
|
||||
|
||||
const d = v0 * m[(0 * 4) + 3] + v1 * m[(1 * 4) + 3] + v2 * m[(2 * 4) + 3] + m[(3 * 4) + 3];
|
||||
dst[0] = (v0 * m[(0 * 4) + 0] + v1 * m[(1 * 4) + 0] + v2 * m[(2 * 4) + 0] + m[(3 * 4) + 0]) / d;
|
||||
dst[1] = (v0 * m[(0 * 4) + 1] + v1 * m[(1 * 4) + 1] + v2 * m[(2 * 4) + 1] + m[(3 * 4) + 1]) / d;
|
||||
dst[2] = (v0 * m[(0 * 4) + 2] + v1 * m[(1 * 4) + 2] + v2 * m[(2 * 4) + 2] + m[(3 * 4) + 2]) / d;
|
||||
```
|
||||
|
||||
As this is a 2D rectangle we can cancel out the third dimension, and the determinant, 'd'.
|
||||
|
||||
```js
|
||||
const v0 = v[0];
|
||||
const v1 = v[1];
|
||||
|
||||
dst = [
|
||||
v0 * m[(0 * 4) + 0] + v1 * m[(1 * 4) + 0] + m[(3 * 4) + 0,
|
||||
v0 * m[(0 * 4) + 1] + v1 * m[(1 * 4) + 1] + m[(3 * 4) + 1
|
||||
];
|
||||
```
|
||||
|
||||
Let's set the matrix points to shorter names for convenience.
|
||||
|
||||
```js
|
||||
const m00 = m[(0 * 4) + 0];
|
||||
const m01 = m[(0 * 4) + 1];
|
||||
const m10 = m[(1 * 4) + 0];
|
||||
const m11 = m[(1 * 4) + 1];
|
||||
const m30 = m[(3 * 4) + 0];
|
||||
const m31 = m[(3 * 4) + 1];
|
||||
```
|
||||
|
||||
We need 4 points with positive and negative 0.5 values so the square has sides of length 1.
|
||||
|
||||
```js
|
||||
let p = [0.5, 0.5];
|
||||
let q = [-0.5, 0.5];
|
||||
let r = [-0.5, -0.5];
|
||||
let s = [0.5, -0.5];
|
||||
```
|
||||
|
||||
Transform the points by the matrix.
|
||||
|
||||
```js
|
||||
p = [
|
||||
0.5 * m00 + 0.5 * m10 + m30,
|
||||
0.5 * m01 + 0.5 * m11 + m31
|
||||
];
|
||||
q = [
|
||||
-0.5 * m00 + -0.5 * m10 + m30,
|
||||
0.5 * m01 + 0.5 * m11 + m31
|
||||
];
|
||||
r = [
|
||||
-0.5 * m00 + -0.5 * m10 + m30,
|
||||
-0.5 * m01 + -0.5 * m11 + m31
|
||||
];
|
||||
s = [
|
||||
0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m01 + -0.5 * m11 + m31
|
||||
];
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```js
|
||||
const left = Math.min(p[0], q[0], r[0], s[0]);
|
||||
const right = Math.max(p[0], q[0], r[0], s[0]);
|
||||
const top = Math.max(p[1], q[1], r[1], s[1]);
|
||||
const bottom = Math.min(p[1], q[1], r[1], s[1]);
|
||||
```
|
||||
|
||||
Fill those calls with the vector expressions.
|
||||
|
||||
```js
|
||||
const left = Math.min(
|
||||
0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m00 + -0.5 * m10 + m30,
|
||||
0.5 * m00 + -0.5 * m10 + m30
|
||||
);
|
||||
const right = Math.max(
|
||||
0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m00 + -0.5 * m10 + m30,
|
||||
0.5 * m00 + -0.5 * m10 + m30
|
||||
);
|
||||
const top = Math.max(
|
||||
0.5 * m01 + 0.5 * m11 + m31,
|
||||
-0.5 * m01 + 0.5 * m11 + m31,
|
||||
-0.5 * m01 + -0.5 * m11 + m31,
|
||||
0.5 * m01 + -0.5 * m11 + m31
|
||||
);
|
||||
const bottom = Math.min(
|
||||
0.5 * m01 + 0.5 * m11 + m31,
|
||||
-0.5 * m01 + 0.5 * m11 + m31,
|
||||
-0.5 * m01 + -0.5 * m11 + m31,
|
||||
0.5 * m01 + -0.5 * m11 + m31
|
||||
);
|
||||
```
|
||||
|
||||
Pull out the `0.5 * m??` patterns.
|
||||
|
||||
```js
|
||||
const x0 = 0.5 * m00;
|
||||
const x1 = 0.5 * m10;
|
||||
const y0 = 0.5 * m01;
|
||||
const y1 = 0.5 * m11;
|
||||
|
||||
const left = Math.min(x0 + x1 + m30, -x0 + x1 + m30, -x0 + -x1 + m30, x0 + -x1 + m30);
|
||||
const right = Math.max(x0 + x1 + m30, -x0 + x1 + m30, -x0 + -x1 + m30, x0 + -x1 + m30);
|
||||
const top = Math.max(y0 + y1 + m31, -y0 + y1 + m31, -y0 + -y1 + m31, y0 + -y1 + m31);
|
||||
const bottom = Math.min(y0 + y1 + m31, -y0 + y1 + m31, -y0 + -y1 + m31, y0 + -y1 + m31);
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```js
|
||||
const left = Math.min(x0, -x0) + Math.min(x1, -x1) + Math.min(m30, m30);
|
||||
const right = Math.max(x0, -x0) + Math.max(x1, -x1) + Math.max(m30, m30);
|
||||
const top = Math.max(y0, -y0) + Math.max(y1, -y1) + Math.max(m31, m31);
|
||||
const bottom = Math.min(y0, -y0) + Math.min(y1, -y1) + Math.min(m31, m31);
|
||||
```
|
||||
|
||||
The min or max of two copies of the same value will just be that value.
|
||||
|
||||
```js
|
||||
const left = Math.min(x0, -x0) + Math.min(x1, -x1) + m30;
|
||||
const right = Math.max(x0, -x0) + Math.max(x1, -x1) + m30;
|
||||
const top = Math.max(y0, -y0) + Math.max(y1, -y1) + m31;
|
||||
const bottom = Math.min(y0, -y0) + Math.min(y1, -y1) + m31;
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```js
|
||||
const left = -Math.abs(x0) + -Math.abs(x1) + m30;
|
||||
const right = Math.abs(x0) + Math.abs(x1) + m30;
|
||||
const top = Math.abs(y0) + Math.abs(y1) + m31;
|
||||
const bottom = -Math.abs(y0) + -Math.abs(y1) + m31;
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```js
|
||||
const left = -(Math.abs(x0) + Math.abs(x1)) + m30;
|
||||
const right = Math.abs(x0) + Math.abs(x1) + m30;
|
||||
const top = Math.abs(y0) + Math.abs(y1) + m31;
|
||||
const bottom = -(Math.abs(y0) + Math.abs(y1)) + m31;
|
||||
```
|
||||
|
||||
We call pull out those sums and use them twice.
|
||||
|
||||
```js
|
||||
const x = Math.abs(x0) + Math.abs(x1);
|
||||
const y = Math.abs(y0) + Math.abs(y1);
|
||||
|
||||
const left = -x + m30;
|
||||
const right = x + m30;
|
||||
const top = y + m31;
|
||||
const bottom = -y + m31;
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```js
|
||||
const m30 = m[(3 * 4) + 0];
|
||||
const m31 = m[(3 * 4) + 1];
|
||||
|
||||
const x = Math.abs(0.5 * m[(0 * 4) + 0]) + Math.abs(0.5 * m[(1 * 4) + 0]);
|
||||
const y = Math.abs(0.5 * m[(0 * 4) + 1]) + Math.abs(0.5 * m[(1 * 4) + 1]);
|
||||
|
||||
const left = -x + m30;
|
||||
const right = x + m30;
|
||||
const top = y + m31;
|
||||
const bottom = -y + m31;
|
||||
```
|
||||
27297
scratch-render/package-lock.json
generated
Normal file
27297
scratch-render/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
scratch-render/package.json
Normal file
60
scratch-render/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "scratch-render",
|
||||
"version": "0.1.0",
|
||||
"description": "WebGL Renderer for Scratch 3.0",
|
||||
"author": "Massachusetts Institute of Technology",
|
||||
"license": "MPL-2.0",
|
||||
"homepage": "https://github.com/LLK/scratch-render#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@github.com/LLK/scratch-render.git"
|
||||
},
|
||||
"main": "./dist/node/scratch-render.js",
|
||||
"browser": "./src/index.js",
|
||||
"scripts": {
|
||||
"build": "webpack --progress --colors",
|
||||
"docs": "jsdoc -c .jsdoc.json",
|
||||
"lint": "eslint .",
|
||||
"prepublishOnly": "npm run build",
|
||||
"prepublish-watch": "npm run watch",
|
||||
"start": "webpack-dev-server",
|
||||
"tap": "tap test/unit test/integration",
|
||||
"test": "npm run lint && npm run build && npm run tap",
|
||||
"version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"",
|
||||
"watch": "webpack --progress --colors --watch --watch-poll"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "6.26.3",
|
||||
"babel-eslint": "10.1.0",
|
||||
"babel-loader": "7.1.5",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"babel-preset-env": "1.7.0",
|
||||
"copy-webpack-plugin": "4.6.0",
|
||||
"docdash": "0.4.0",
|
||||
"eslint": "8.55.0",
|
||||
"eslint-config-scratch": "9.0.3",
|
||||
"gh-pages": "1.2.0",
|
||||
"jsdoc": "3.6.7",
|
||||
"json": "9.0.6",
|
||||
"playwright-chromium": "1.13.0",
|
||||
"scratch-vm": "0.2.0-prerelease.20201125065300",
|
||||
"tap": "11.1.5",
|
||||
"travis-after-all": "1.4.5",
|
||||
"uglifyjs-webpack-plugin": "1.3.0",
|
||||
"webpack": "4.47.0",
|
||||
"webpack-cli": "3.3.12",
|
||||
"webpack-dev-server": "3.11.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@turbowarp/nanolog": "^0.2.0",
|
||||
"@turbowarp/scratch-svg-renderer": "^1.0.0-202312242305-12c360b",
|
||||
"grapheme-breaker": "0.3.2",
|
||||
"hull.js": "0.2.10",
|
||||
"ify-loader": "1.0.4",
|
||||
"linebreak": "0.3.0",
|
||||
"raw-loader": "^0.5.1",
|
||||
"scratch-storage": "^1.0.0",
|
||||
"twgl.js": "4.4.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
18
scratch-render/renovate.json5
Normal file
18
scratch-render/renovate.json5
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
|
||||
"extends": [
|
||||
"github>LLK/scratch-renovate-config"
|
||||
],
|
||||
|
||||
"packageRules": [
|
||||
// Don't bump scratch-render's version number when merging a scratch-vm update
|
||||
// since that will cause a never-ending cycle of dependency updates.
|
||||
{
|
||||
"description": "don't bump scratch-render version when updating scratch-vm",
|
||||
"automerge": false, // disable this once scratch-render uses semantic-release instead of date-based versioning
|
||||
"matchPackageNames": ["scratch-vm"],
|
||||
"semanticCommitType": "test" // scratch-vm is a dependency of scratch-render tests only
|
||||
}
|
||||
]
|
||||
}
|
||||
11
scratch-render/src/.eslintrc.js
Normal file
11
scratch-render/src/.eslintrc.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['scratch', 'scratch/es6', 'scratch/node'],
|
||||
env: {
|
||||
node: false,
|
||||
browser: true // TODO: disable this
|
||||
},
|
||||
globals: {
|
||||
Buffer: true // TODO: remove this?
|
||||
}
|
||||
};
|
||||
116
scratch-render/src/BitmapSkin.js
Normal file
116
scratch-render/src/BitmapSkin.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const twgl = require('twgl.js');
|
||||
|
||||
const Skin = require('./Skin');
|
||||
|
||||
class BitmapSkin extends Skin {
|
||||
/**
|
||||
* Create a new Bitmap Skin.
|
||||
* @extends Skin
|
||||
* @param {!int} id - The ID for this Skin.
|
||||
* @param {!RenderWebGL} renderer - The renderer which will use this skin.
|
||||
*/
|
||||
constructor (id, renderer) {
|
||||
super(id, renderer);
|
||||
|
||||
/** @type {!int} */
|
||||
this._costumeResolution = 1;
|
||||
|
||||
/** @type {Array<int>} */
|
||||
this._textureSize = [0, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of this object. Do not use it after calling this method.
|
||||
*/
|
||||
dispose () {
|
||||
if (this._texture) {
|
||||
this._renderer.gl.deleteTexture(this._texture);
|
||||
this._texture = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<number>} the "native" size, in texels, of this skin.
|
||||
*/
|
||||
get size () {
|
||||
return [this._textureSize[0] / this._costumeResolution, this._textureSize[1] / this._costumeResolution];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<number>} scale - The scaling factors to be used.
|
||||
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
getTexture (scale) {
|
||||
return this._texture || super.getTexture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the contents of this skin to a snapshot of the provided bitmap data.
|
||||
* @param {ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} bitmapData - new contents for this skin.
|
||||
* @param {int} [costumeResolution=1] - The resolution to use for this bitmap.
|
||||
* @param {Array<number>} [rotationCenter] - Optional rotation center for the bitmap. If not supplied, it will be
|
||||
* calculated from the bounding box
|
||||
* @fires Skin.event:WasAltered
|
||||
*/
|
||||
setBitmap (bitmapData, costumeResolution, rotationCenter) {
|
||||
if (!bitmapData.width || !bitmapData.height) {
|
||||
super.setEmptyImageData();
|
||||
return;
|
||||
}
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
// TW: We want to use <canvas> as-is because reading ImageData wastes memory.
|
||||
// However, vanilla LLK/scratch-vm will reuse any canvas that we get here for other costumes,
|
||||
// which will cause bugs when Silhouette lazily reads the canvas data.
|
||||
// TurboWarp/scratch-vm does not reuse canvases and will set canvas.reusable = false.
|
||||
let textureData = bitmapData;
|
||||
if (bitmapData instanceof HTMLCanvasElement && bitmapData.reusable !== false) {
|
||||
const context = bitmapData.getContext('2d');
|
||||
textureData = context.getImageData(0, 0, bitmapData.width, bitmapData.height);
|
||||
}
|
||||
|
||||
if (this._texture === null) {
|
||||
const textureOptions = {
|
||||
auto: false,
|
||||
wrap: gl.CLAMP_TO_EDGE
|
||||
};
|
||||
|
||||
this._texture = twgl.createTexture(gl, textureOptions);
|
||||
}
|
||||
|
||||
this._setTexture(textureData);
|
||||
|
||||
// Do these last in case any of the above throws an exception
|
||||
this._costumeResolution = costumeResolution || 2;
|
||||
this._textureSize = BitmapSkin._getBitmapSize(bitmapData);
|
||||
|
||||
if (typeof rotationCenter === 'undefined') rotationCenter = this.calculateRotationCenter();
|
||||
this._rotationCenter[0] = rotationCenter[0];
|
||||
this._rotationCenter[1] = rotationCenter[1];
|
||||
|
||||
this.emitWasAltered();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} bitmapData - bitmap data to inspect.
|
||||
* @returns {Array<int>} the width and height of the bitmap data, in pixels.
|
||||
* @private
|
||||
*/
|
||||
static _getBitmapSize (bitmapData) {
|
||||
if (bitmapData instanceof HTMLImageElement) {
|
||||
return [bitmapData.naturalWidth || bitmapData.width, bitmapData.naturalHeight || bitmapData.height];
|
||||
}
|
||||
|
||||
if (bitmapData instanceof HTMLVideoElement) {
|
||||
return [bitmapData.videoWidth || bitmapData.width, bitmapData.videoHeight || bitmapData.height];
|
||||
}
|
||||
|
||||
// ImageData or HTMLCanvasElement
|
||||
return [bitmapData.width, bitmapData.height];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = BitmapSkin;
|
||||
745
scratch-render/src/Drawable.js
Normal file
745
scratch-render/src/Drawable.js
Normal file
@@ -0,0 +1,745 @@
|
||||
const twgl = require('twgl.js');
|
||||
|
||||
const Rectangle = require('./Rectangle');
|
||||
const RenderConstants = require('./RenderConstants');
|
||||
const ShaderManager = require('./ShaderManager');
|
||||
const EffectTransform = require('./EffectTransform');
|
||||
const log = require('./util/log');
|
||||
|
||||
/**
|
||||
* An internal workspace for calculating texture locations from world vectors
|
||||
* this is REUSED for memory conservation reasons
|
||||
* @type {twgl.v3}
|
||||
*/
|
||||
const __isTouchingPosition = twgl.v3.create();
|
||||
const FLOATING_POINT_ERROR_ALLOWANCE = 1e-6;
|
||||
|
||||
/**
|
||||
* Convert a scratch space location into a texture space float. Uses the
|
||||
* internal __isTouchingPosition as a return value, so this should be copied
|
||||
* if you ever need to get two local positions and store both. Requires that
|
||||
* the drawable inverseMatrix is up to date.
|
||||
*
|
||||
* @param {Drawable} drawable The drawable to get the inverse matrix and uniforms from
|
||||
* @param {twgl.v3} vec [x,y] scratch space vector
|
||||
* @return {twgl.v3} [x,y] texture space float vector - transformed by effects and matrix
|
||||
*/
|
||||
const getLocalPosition = (drawable, vec) => {
|
||||
// Transfrom from world coordinates to Drawable coordinates.
|
||||
const localPosition = __isTouchingPosition;
|
||||
const v0 = vec[0];
|
||||
const v1 = vec[1];
|
||||
const m = drawable._inverseMatrix;
|
||||
// var v2 = v[2];
|
||||
const d = (v0 * m[3]) + (v1 * m[7]) + m[15];
|
||||
// The RenderWebGL quad flips the texture's X axis. So rendered bottom
|
||||
// left is 1, 0 and the top right is 0, 1. Flip the X axis so
|
||||
// localPosition matches that transformation.
|
||||
localPosition[0] = 0.5 - (((v0 * m[0]) + (v1 * m[4]) + m[12]) / d);
|
||||
localPosition[1] = (((v0 * m[1]) + (v1 * m[5]) + m[13]) / d) + 0.5;
|
||||
// Fix floating point issues near 0. Filed https://github.com/LLK/scratch-render/issues/688 that
|
||||
// they're happening in the first place.
|
||||
// TODO: Check if this can be removed after render pull 479 is merged
|
||||
if (Math.abs(localPosition[0]) < FLOATING_POINT_ERROR_ALLOWANCE) localPosition[0] = 0;
|
||||
if (Math.abs(localPosition[1]) < FLOATING_POINT_ERROR_ALLOWANCE) localPosition[1] = 0;
|
||||
// Apply texture effect transform if the localPosition is within the drawable's space,
|
||||
// and any effects are currently active.
|
||||
if (drawable.enabledEffects !== 0 &&
|
||||
(localPosition[0] >= 0 && localPosition[0] < 1) &&
|
||||
(localPosition[1] >= 0 && localPosition[1] < 1)) {
|
||||
|
||||
EffectTransform.transformPoint(drawable, localPosition, localPosition);
|
||||
}
|
||||
return localPosition;
|
||||
};
|
||||
|
||||
class Drawable {
|
||||
/**
|
||||
* An object which can be drawn by the renderer.
|
||||
* @todo double-buffer all rendering state (position, skin, effects, etc.)
|
||||
* @param {!int} id - This Drawable's unique ID.
|
||||
* @param {!RenderWebGL} renderer - The renderer that created this Drawable
|
||||
* @constructor
|
||||
*/
|
||||
constructor (id, renderer) {
|
||||
/** @type {!int} */
|
||||
this._id = id;
|
||||
this._renderer = renderer;
|
||||
|
||||
/**
|
||||
* The uniforms to be used by the vertex and pixel shaders.
|
||||
* Some of these are used by other parts of the renderer as well.
|
||||
* @type {Object.<string,*>}
|
||||
* @private
|
||||
*/
|
||||
this._uniforms = {
|
||||
/**
|
||||
* The model matrix, to concat with projection at draw time.
|
||||
* @type {module:twgl/m4.Mat4}
|
||||
*/
|
||||
u_modelMatrix: twgl.m4.identity(),
|
||||
|
||||
/**
|
||||
* The color to use in the silhouette draw mode.
|
||||
* @type {Array<number>}
|
||||
*/
|
||||
u_silhouetteColor: Drawable.color4fFromID(this._id)
|
||||
};
|
||||
|
||||
// Effect values are uniforms too
|
||||
const numEffects = ShaderManager.EFFECTS.length;
|
||||
for (let index = 0; index < numEffects; ++index) {
|
||||
const effectName = ShaderManager.EFFECTS[index];
|
||||
const effectInfo = ShaderManager.EFFECT_INFO[effectName];
|
||||
const converter = effectInfo.converter;
|
||||
this._uniforms[effectInfo.uniformName] = converter(0);
|
||||
}
|
||||
|
||||
this._position = twgl.v3.create(0, 0);
|
||||
this._scale = twgl.v3.create(100, 100);
|
||||
this._direction = 90;
|
||||
this._transformDirty = true;
|
||||
this._rotationMatrix = twgl.m4.identity();
|
||||
this._rotationTransformDirty = true;
|
||||
this._rotationAdjusted = twgl.v3.create();
|
||||
this._rotationCenterDirty = true;
|
||||
this._skinScale = twgl.v3.create(0, 0, 0);
|
||||
this._skinScaleDirty = true;
|
||||
this._inverseMatrix = twgl.m4.identity();
|
||||
this._inverseTransformDirty = true;
|
||||
this._visible = true;
|
||||
|
||||
/** A bitmask identifying which effects are currently in use.
|
||||
* @readonly
|
||||
* @type {int} */
|
||||
this.enabledEffects = 0;
|
||||
|
||||
/** @todo move convex hull functionality, maybe bounds functionality overall, to Skin classes */
|
||||
this._convexHullPoints = null;
|
||||
this._convexHullDirty = true;
|
||||
|
||||
// The precise bounding box will be from the transformed convex hull points,
|
||||
// so initialize the array of transformed hull points in setConvexHullPoints.
|
||||
// Initializing it once per convex hull recalculation avoids unnecessary creation of twgl.v3 objects.
|
||||
this._transformedHullPoints = null;
|
||||
this._transformedHullDirty = true;
|
||||
|
||||
this._skinWasAltered = this._skinWasAltered.bind(this);
|
||||
|
||||
this.isTouching = this._isTouchingNever;
|
||||
|
||||
this._highQuality = false;
|
||||
}
|
||||
|
||||
setHighQuality (highQuality) {
|
||||
this._highQuality = highQuality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of this Drawable. Do not use it after calling this method.
|
||||
*/
|
||||
dispose () {
|
||||
// Use the setter: disconnect events
|
||||
this.skin = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this Drawable's transform as dirty.
|
||||
* It will be recalculated next time it's needed.
|
||||
*/
|
||||
setTransformDirty () {
|
||||
this._transformDirty = true;
|
||||
this._inverseTransformDirty = true;
|
||||
this._transformedHullDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number} The ID for this Drawable.
|
||||
*/
|
||||
get id () {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Skin} the current skin for this Drawable.
|
||||
*/
|
||||
get skin () {
|
||||
return this._skin;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Skin} newSkin - A new Skin for this Drawable.
|
||||
*/
|
||||
set skin (newSkin) {
|
||||
if (this._skin !== newSkin) {
|
||||
this._skin = newSkin;
|
||||
this._skinWasAltered();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array<number>} the current scaling percentages applied to this Drawable. [100,100] is normal size.
|
||||
*/
|
||||
get scale () {
|
||||
return [this._scale[0], this._scale[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object.<string, *>} the shader uniforms to be used when rendering this Drawable.
|
||||
*/
|
||||
getUniforms () {
|
||||
if (this._transformDirty) {
|
||||
this._calculateTransform();
|
||||
}
|
||||
return this._uniforms;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} whether this Drawable is visible.
|
||||
*/
|
||||
getVisible () {
|
||||
return this._visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the position if it is different. Marks the transform as dirty.
|
||||
* @param {Array.<number>} position A new position.
|
||||
*/
|
||||
updatePosition (position) {
|
||||
if (this._position[0] !== position[0] ||
|
||||
this._position[1] !== position[1]) {
|
||||
if (this._highQuality) {
|
||||
this._position[0] = position[0];
|
||||
this._position[1] = position[1];
|
||||
} else {
|
||||
this._position[0] = Math.round(position[0]);
|
||||
this._position[1] = Math.round(position[1]);
|
||||
}
|
||||
this._renderer.dirty = true;
|
||||
this.setTransformDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the direction if it is different. Marks the transform as dirty.
|
||||
* @param {number} direction A new direction.
|
||||
*/
|
||||
updateDirection (direction) {
|
||||
if (this._direction !== direction) {
|
||||
this._direction = direction;
|
||||
this._renderer.dirty = true;
|
||||
this._rotationTransformDirty = true;
|
||||
this.setTransformDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the scale if it is different. Marks the transform as dirty.
|
||||
* @param {Array.<number>} scale A new scale.
|
||||
*/
|
||||
updateScale (scale) {
|
||||
if (this._scale[0] !== scale[0] ||
|
||||
this._scale[1] !== scale[1]) {
|
||||
this._scale[0] = scale[0];
|
||||
this._scale[1] = scale[1];
|
||||
this._renderer.dirty = true;
|
||||
this._rotationCenterDirty = true;
|
||||
this._skinScaleDirty = true;
|
||||
this.setTransformDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visibility if it is different. Marks the convex hull as dirty.
|
||||
* @param {boolean} visible A new visibility state.
|
||||
*/
|
||||
updateVisible (visible) {
|
||||
if (this._visible !== visible) {
|
||||
this._visible = visible;
|
||||
this._renderer.dirty = true;
|
||||
this.setConvexHullDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an effect. Marks the convex hull as dirty if the effect changes shape.
|
||||
* @param {string} effectName The name of the effect.
|
||||
* @param {number} rawValue A new effect value.
|
||||
*/
|
||||
updateEffect (effectName, rawValue) {
|
||||
this._renderer.dirty = true;
|
||||
const effectInfo = ShaderManager.EFFECT_INFO[effectName];
|
||||
if (rawValue) {
|
||||
this.enabledEffects |= effectInfo.mask;
|
||||
} else {
|
||||
this.enabledEffects &= ~effectInfo.mask;
|
||||
}
|
||||
const converter = effectInfo.converter;
|
||||
this._uniforms[effectInfo.uniformName] = converter(rawValue);
|
||||
if (effectInfo.shapeChanges) {
|
||||
this.setConvexHullDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the position, direction, scale, or effect properties of this Drawable.
|
||||
* @deprecated Use specific update* methods instead.
|
||||
* @param {object.<string,*>} properties The new property values to set.
|
||||
*/
|
||||
updateProperties (properties) {
|
||||
if ('position' in properties) {
|
||||
this.updatePosition(properties.position);
|
||||
}
|
||||
if ('direction' in properties) {
|
||||
this.updateDirection(properties.direction);
|
||||
}
|
||||
if ('scale' in properties) {
|
||||
this.updateScale(properties.scale);
|
||||
}
|
||||
if ('visible' in properties) {
|
||||
this.updateVisible(properties.visible);
|
||||
}
|
||||
const numEffects = ShaderManager.EFFECTS.length;
|
||||
for (let index = 0; index < numEffects; ++index) {
|
||||
const effectName = ShaderManager.EFFECTS[index];
|
||||
if (effectName in properties) {
|
||||
this.updateEffect(effectName, properties[effectName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the transform to use when rendering this Drawable.
|
||||
* @private
|
||||
*/
|
||||
_calculateTransform () {
|
||||
if (this._rotationTransformDirty) {
|
||||
const rotation = (270 - this._direction) * Math.PI / 180;
|
||||
|
||||
// Calling rotationZ sets the destination matrix to a rotation
|
||||
// around the Z axis setting matrix components 0, 1, 4 and 5 with
|
||||
// cosine and sine values of the rotation.
|
||||
// twgl.m4.rotationZ(rotation, this._rotationMatrix);
|
||||
|
||||
// twgl assumes the last value set to the matrix was anything.
|
||||
// Drawable knows, it was another rotationZ matrix, so we can skip
|
||||
// assigning the values that will never change.
|
||||
const c = Math.cos(rotation);
|
||||
const s = Math.sin(rotation);
|
||||
this._rotationMatrix[0] = c;
|
||||
this._rotationMatrix[1] = s;
|
||||
// this._rotationMatrix[2] = 0;
|
||||
// this._rotationMatrix[3] = 0;
|
||||
this._rotationMatrix[4] = -s;
|
||||
this._rotationMatrix[5] = c;
|
||||
// this._rotationMatrix[6] = 0;
|
||||
// this._rotationMatrix[7] = 0;
|
||||
// this._rotationMatrix[8] = 0;
|
||||
// this._rotationMatrix[9] = 0;
|
||||
// this._rotationMatrix[10] = 1;
|
||||
// this._rotationMatrix[11] = 0;
|
||||
// this._rotationMatrix[12] = 0;
|
||||
// this._rotationMatrix[13] = 0;
|
||||
// this._rotationMatrix[14] = 0;
|
||||
// this._rotationMatrix[15] = 1;
|
||||
|
||||
this._rotationTransformDirty = false;
|
||||
}
|
||||
|
||||
// Adjust rotation center relative to the skin.
|
||||
if (this._rotationCenterDirty && this.skin !== null) {
|
||||
// twgl version of the following in function work.
|
||||
// let rotationAdjusted = twgl.v3.subtract(
|
||||
// this.skin.rotationCenter,
|
||||
// twgl.v3.divScalar(this.skin.size, 2, this._rotationAdjusted),
|
||||
// this._rotationAdjusted
|
||||
// );
|
||||
// rotationAdjusted = twgl.v3.multiply(
|
||||
// rotationAdjusted, this._scale, rotationAdjusted
|
||||
// );
|
||||
// rotationAdjusted = twgl.v3.divScalar(
|
||||
// rotationAdjusted, 100, rotationAdjusted
|
||||
// );
|
||||
// rotationAdjusted[1] *= -1; // Y flipped to Scratch coordinate.
|
||||
// rotationAdjusted[2] = 0; // Z coordinate is 0.
|
||||
|
||||
// Locally assign rotationCenter and skinSize to keep from having
|
||||
// the Skin getter properties called twice while locally assigning
|
||||
// their components for readability.
|
||||
const rotationCenter = this.skin.rotationCenter;
|
||||
const skinSize = this.skin.size;
|
||||
const center0 = rotationCenter[0];
|
||||
const center1 = rotationCenter[1];
|
||||
const skinSize0 = skinSize[0];
|
||||
const skinSize1 = skinSize[1];
|
||||
const scale0 = this._scale[0];
|
||||
const scale1 = this._scale[1];
|
||||
|
||||
const rotationAdjusted = this._rotationAdjusted;
|
||||
rotationAdjusted[0] = (center0 - (skinSize0 / 2)) * scale0 / 100;
|
||||
rotationAdjusted[1] = ((center1 - (skinSize1 / 2)) * scale1 / 100) * -1;
|
||||
// rotationAdjusted[2] = 0;
|
||||
|
||||
this._rotationCenterDirty = false;
|
||||
}
|
||||
|
||||
if (this._skinScaleDirty && this.skin !== null) {
|
||||
// twgl version of the following in function work.
|
||||
// const scaledSize = twgl.v3.divScalar(
|
||||
// twgl.v3.multiply(this.skin.size, this._scale),
|
||||
// 100
|
||||
// );
|
||||
// // was NaN because the vectors have only 2 components.
|
||||
// scaledSize[2] = 0;
|
||||
|
||||
// Locally assign skinSize to keep from having the Skin getter
|
||||
// properties called twice.
|
||||
const skinSize = this.skin.size;
|
||||
const scaledSize = this._skinScale;
|
||||
scaledSize[0] = skinSize[0] * this._scale[0] / 100;
|
||||
scaledSize[1] = skinSize[1] * this._scale[1] / 100;
|
||||
// scaledSize[2] = 0;
|
||||
|
||||
this._skinScaleDirty = false;
|
||||
}
|
||||
|
||||
const modelMatrix = this._uniforms.u_modelMatrix;
|
||||
|
||||
// twgl version of the following in function work.
|
||||
// twgl.m4.identity(modelMatrix);
|
||||
// twgl.m4.translate(modelMatrix, this._position, modelMatrix);
|
||||
// twgl.m4.multiply(modelMatrix, this._rotationMatrix, modelMatrix);
|
||||
// twgl.m4.translate(modelMatrix, this._rotationAdjusted, modelMatrix);
|
||||
// twgl.m4.scale(modelMatrix, scaledSize, modelMatrix);
|
||||
|
||||
// Drawable configures a 3D matrix for drawing in WebGL, but most values
|
||||
// will never be set because the inputs are on the X and Y position axis
|
||||
// and the Z rotation axis. Drawable can bring the work inside
|
||||
// _calculateTransform and greatly reduce the ammount of math and array
|
||||
// assignments needed.
|
||||
|
||||
const scale0 = this._skinScale[0];
|
||||
const scale1 = this._skinScale[1];
|
||||
const rotation00 = this._rotationMatrix[0];
|
||||
const rotation01 = this._rotationMatrix[1];
|
||||
const rotation10 = this._rotationMatrix[4];
|
||||
const rotation11 = this._rotationMatrix[5];
|
||||
const adjusted0 = this._rotationAdjusted[0];
|
||||
const adjusted1 = this._rotationAdjusted[1];
|
||||
const position0 = this._position[0];
|
||||
const position1 = this._position[1];
|
||||
|
||||
// Commented assignments show what the values are when the matrix was
|
||||
// instantiated. Those values will never change so they do not need to
|
||||
// be reassigned.
|
||||
modelMatrix[0] = scale0 * rotation00;
|
||||
modelMatrix[1] = scale0 * rotation01;
|
||||
// modelMatrix[2] = 0;
|
||||
// modelMatrix[3] = 0;
|
||||
modelMatrix[4] = scale1 * rotation10;
|
||||
modelMatrix[5] = scale1 * rotation11;
|
||||
// modelMatrix[6] = 0;
|
||||
// modelMatrix[7] = 0;
|
||||
// modelMatrix[8] = 0;
|
||||
// modelMatrix[9] = 0;
|
||||
// modelMatrix[10] = 1;
|
||||
// modelMatrix[11] = 0;
|
||||
modelMatrix[12] = (rotation00 * adjusted0) + (rotation10 * adjusted1) + position0;
|
||||
modelMatrix[13] = (rotation01 * adjusted0) + (rotation11 * adjusted1) + position1;
|
||||
// modelMatrix[14] = 0;
|
||||
// modelMatrix[15] = 1;
|
||||
|
||||
this._transformDirty = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Drawable needs convex hull points provided by the renderer.
|
||||
* @return {boolean} True when no convex hull known, or it's dirty.
|
||||
*/
|
||||
needsConvexHullPoints () {
|
||||
return !this._convexHullPoints || this._convexHullDirty || this._convexHullPoints.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the convex hull to be dirty.
|
||||
* Do this whenever the Drawable's shape has possibly changed.
|
||||
*/
|
||||
setConvexHullDirty () {
|
||||
this._convexHullDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the convex hull points for the Drawable.
|
||||
* @param {Array<Array<number>>} points Convex hull points, as [[x, y], ...]
|
||||
*/
|
||||
setConvexHullPoints (points) {
|
||||
this._convexHullPoints = points;
|
||||
this._convexHullDirty = false;
|
||||
|
||||
// Re-create the "transformed hull points" array.
|
||||
// We only do this when the hull points change to avoid unnecessary allocations and GC.
|
||||
this._transformedHullPoints = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
this._transformedHullPoints.push(twgl.v3.create());
|
||||
}
|
||||
this._transformedHullDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @name isTouching
|
||||
* Check if the world position touches the skin.
|
||||
* The caller is responsible for ensuring this drawable's inverse matrix & its skin's silhouette are up-to-date.
|
||||
* @see updateCPURenderAttributes
|
||||
* @param {twgl.v3} vec World coordinate vector.
|
||||
* @return {boolean} True if the world position touches the skin.
|
||||
*/
|
||||
|
||||
// `updateCPURenderAttributes` sets this Drawable instance's `isTouching` method
|
||||
// to one of the following three functions:
|
||||
// If this drawable has no skin, set it to `_isTouchingNever`.
|
||||
// Otherwise, if this drawable uses nearest-neighbor scaling at its current scale, set it to `_isTouchingNearest`.
|
||||
// Otherwise, set it to `_isTouchingLinear`.
|
||||
// This allows several checks to be moved from the `isTouching` function to `updateCPURenderAttributes`.
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_isTouchingNever (vec) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_isTouchingNearest (vec) {
|
||||
return this.skin.isTouchingNearest(getLocalPosition(this, vec));
|
||||
}
|
||||
|
||||
_isTouchingLinear (vec) {
|
||||
return this.skin.isTouchingLinear(getLocalPosition(this, vec));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the precise bounds for a Drawable.
|
||||
* This function applies the transform matrix to the known convex hull,
|
||||
* and then finds the minimum box along the axes.
|
||||
* Before calling this, ensure the renderer has updated convex hull points.
|
||||
* @param {?Rectangle} result optional destination for bounds calculation
|
||||
* @return {!Rectangle} Bounds for a tight box around the Drawable.
|
||||
*/
|
||||
getBounds (result) {
|
||||
if (this.needsConvexHullPoints()) {
|
||||
throw new Error('Needs updated convex hull points before bounds calculation.');
|
||||
}
|
||||
if (this._transformDirty) {
|
||||
this._calculateTransform();
|
||||
}
|
||||
const transformedHullPoints = this._getTransformedHullPoints();
|
||||
// Search through transformed points to generate box on axes.
|
||||
result = result || new Rectangle();
|
||||
result.initFromPointsAABB(transformedHullPoints);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the precise bounds for the upper 8px slice of the Drawable.
|
||||
* Used for calculating where to position a text bubble.
|
||||
* Before calling this, ensure the renderer has updated convex hull points.
|
||||
* @param {?Rectangle} result optional destination for bounds calculation
|
||||
* @return {!Rectangle} Bounds for a tight box around a slice of the Drawable.
|
||||
*/
|
||||
getBoundsForBubble (result) {
|
||||
if (this.needsConvexHullPoints()) {
|
||||
throw new Error('Needs updated convex hull points before bubble bounds calculation.');
|
||||
}
|
||||
if (this._transformDirty) {
|
||||
this._calculateTransform();
|
||||
}
|
||||
const slice = 8; // px, how tall the top slice to measure should be.
|
||||
const transformedHullPoints = this._getTransformedHullPoints();
|
||||
const maxY = Math.max.apply(null, transformedHullPoints.map(p => p[1]));
|
||||
const filteredHullPoints = transformedHullPoints.filter(p => p[1] > maxY - slice);
|
||||
// Search through filtered points to generate box on axes.
|
||||
result = result || new Rectangle();
|
||||
result.initFromPointsAABB(filteredHullPoints);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rough axis-aligned bounding box for the Drawable.
|
||||
* Calculated by transforming the skin's bounds.
|
||||
* Note that this is less precise than the box returned by `getBounds`,
|
||||
* which is tightly snapped to account for a Drawable's transparent regions.
|
||||
* `getAABB` returns a much less accurate bounding box, but will be much
|
||||
* faster to calculate so may be desired for quick checks/optimizations.
|
||||
* @param {?Rectangle} result optional destination for bounds calculation
|
||||
* @return {!Rectangle} Rough axis-aligned bounding box for Drawable.
|
||||
*/
|
||||
getAABB (result) {
|
||||
if (this._transformDirty) {
|
||||
this._calculateTransform();
|
||||
}
|
||||
const tm = this._uniforms.u_modelMatrix;
|
||||
result = result || new Rectangle();
|
||||
result.initFromModelMatrix(tm);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the best Drawable bounds possible without performing graphics queries.
|
||||
* I.e., returns the tight bounding box when the convex hull points are already
|
||||
* known, but otherwise return the rough AABB of the Drawable.
|
||||
* @param {?Rectangle} result optional destination for bounds calculation
|
||||
* @return {!Rectangle} Bounds for the Drawable.
|
||||
*/
|
||||
getFastBounds (result) {
|
||||
if (!this.needsConvexHullPoints()) {
|
||||
return this.getBounds(result);
|
||||
}
|
||||
return this.getAABB(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform all the convex hull points by the current Drawable's
|
||||
* transform. This allows us to skip recalculating the convex hull
|
||||
* for many Drawable updates, including translation, rotation, scaling.
|
||||
* @return {!Array.<!Array.number>} Array of glPoints which are Array<x, y>
|
||||
* @private
|
||||
*/
|
||||
_getTransformedHullPoints () {
|
||||
if (!this._transformedHullDirty) {
|
||||
return this._transformedHullPoints;
|
||||
}
|
||||
|
||||
const projection = twgl.m4.ortho(-1, 1, -1, 1, -1, 1);
|
||||
const skinSize = this.skin.size;
|
||||
const halfXPixel = 1 / skinSize[0] / 2;
|
||||
const halfYPixel = 1 / skinSize[1] / 2;
|
||||
const tm = twgl.m4.multiply(this._uniforms.u_modelMatrix, projection);
|
||||
for (let i = 0; i < this._convexHullPoints.length; i++) {
|
||||
const point = this._convexHullPoints[i];
|
||||
const dstPoint = this._transformedHullPoints[i];
|
||||
|
||||
dstPoint[0] = 0.5 + (-point[0] / skinSize[0]) - halfXPixel;
|
||||
dstPoint[1] = (point[1] / skinSize[1]) - 0.5 + halfYPixel;
|
||||
twgl.m4.transformPoint(tm, dstPoint, dstPoint);
|
||||
}
|
||||
|
||||
this._transformedHullDirty = false;
|
||||
|
||||
return this._transformedHullPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the transform matrix and calculate it's inverse for collision
|
||||
* and local texture position purposes.
|
||||
*/
|
||||
updateMatrix () {
|
||||
if (this._transformDirty) {
|
||||
this._calculateTransform();
|
||||
}
|
||||
// Get the inverse of the model matrix or update it.
|
||||
if (this._inverseTransformDirty) {
|
||||
const inverse = this._inverseMatrix;
|
||||
twgl.m4.copy(this._uniforms.u_modelMatrix, inverse);
|
||||
// The normal matrix uses a z scaling of 0 causing model[10] to be
|
||||
// 0. Getting a 4x4 inverse is impossible without a scaling in x, y,
|
||||
// and z.
|
||||
inverse[10] = 1;
|
||||
twgl.m4.inverse(inverse, inverse);
|
||||
this._inverseTransformDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update everything necessary to render this drawable on the CPU.
|
||||
*/
|
||||
updateCPURenderAttributes () {
|
||||
this.updateMatrix();
|
||||
// CPU rendering always occurs at the "native" size, so no need to scale up this._scale
|
||||
if (this.skin) {
|
||||
this.skin.updateSilhouette(this._scale);
|
||||
|
||||
if (this.skin.useNearest(this._scale, this)) {
|
||||
this.isTouching = this._isTouchingNearest;
|
||||
} else {
|
||||
this.isTouching = this._isTouchingLinear;
|
||||
}
|
||||
} else {
|
||||
log.warn(`Could not find skin for drawable with id: ${this._id}`);
|
||||
|
||||
this.isTouching = this._isTouchingNever;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to an internal change in the current Skin.
|
||||
*/
|
||||
_skinWasAltered () {
|
||||
this._renderer.dirty = true;
|
||||
this._rotationCenterDirty = true;
|
||||
this._skinScaleDirty = true;
|
||||
this.setConvexHullDirty();
|
||||
this.setTransformDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a color to represent the given ID number. At least one component of
|
||||
* the resulting color will be non-zero if the ID is not RenderConstants.ID_NONE.
|
||||
* @param {int} id The ID to convert.
|
||||
* @returns {Array<number>} An array of [r,g,b,a], each component in the range [0,1].
|
||||
*/
|
||||
static color4fFromID (id) {
|
||||
id -= RenderConstants.ID_NONE;
|
||||
const r = ((id >> 0) & 255) / 255.0;
|
||||
const g = ((id >> 8) & 255) / 255.0;
|
||||
const b = ((id >> 16) & 255) / 255.0;
|
||||
return [r, g, b, 1.0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the ID number represented by the given color. If all components of
|
||||
* the color are zero, the result will be RenderConstants.ID_NONE; otherwise the result
|
||||
* will be a valid ID.
|
||||
* @param {int} r The red value of the color, in the range [0,255].
|
||||
* @param {int} g The green value of the color, in the range [0,255].
|
||||
* @param {int} b The blue value of the color, in the range [0,255].
|
||||
* @returns {int} The ID represented by that color.
|
||||
*/
|
||||
static color3bToID (r, g, b) {
|
||||
let id;
|
||||
id = (r & 255) << 0;
|
||||
id |= (g & 255) << 8;
|
||||
id |= (b & 255) << 16;
|
||||
return id + RenderConstants.ID_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a color from a drawable's texture.
|
||||
* The caller is responsible for ensuring this drawable's inverse matrix & its skin's silhouette are up-to-date.
|
||||
* @see updateCPURenderAttributes
|
||||
* @param {twgl.v3} vec The scratch space [x,y] vector
|
||||
* @param {Drawable} drawable The drawable to sample the texture from
|
||||
* @param {Uint8ClampedArray} dst The "color4b" representation of the texture at point.
|
||||
* @param {number} [effectMask] A bitmask for which effects to use. Optional.
|
||||
* @returns {Uint8ClampedArray} The dst object filled with the color4b
|
||||
*/
|
||||
static sampleColor4b (vec, drawable, dst, effectMask) {
|
||||
const localPosition = getLocalPosition(drawable, vec);
|
||||
if (localPosition[0] < 0 || localPosition[1] < 0 ||
|
||||
localPosition[0] > 1 || localPosition[1] > 1) {
|
||||
dst[0] = 0;
|
||||
dst[1] = 0;
|
||||
dst[2] = 0;
|
||||
dst[3] = 0;
|
||||
return dst;
|
||||
}
|
||||
|
||||
const textColor =
|
||||
// commenting out to only use nearest for now
|
||||
// drawable.skin.useNearest(drawable._scale, drawable) ?
|
||||
drawable.skin._silhouette.colorAtNearest(localPosition, dst);
|
||||
// : drawable.skin._silhouette.colorAtLinear(localPosition, dst);
|
||||
|
||||
if (drawable.enabledEffects === 0) return textColor;
|
||||
return EffectTransform.transformColor(drawable, textColor, effectMask);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Drawable;
|
||||
197
scratch-render/src/EffectTransform.js
Normal file
197
scratch-render/src/EffectTransform.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* A utility to transform a texture coordinate to another texture coordinate
|
||||
* representing how the shaders apply effects.
|
||||
*/
|
||||
|
||||
const twgl = require('twgl.js');
|
||||
|
||||
const {rgbToHsv, hsvToRgb} = require('./util/color-conversions');
|
||||
const ShaderManager = require('./ShaderManager');
|
||||
|
||||
/**
|
||||
* A texture coordinate is between 0 and 1. 0.5 is the center position.
|
||||
* @const {number}
|
||||
*/
|
||||
const CENTER_X = 0.5;
|
||||
|
||||
/**
|
||||
* A texture coordinate is between 0 and 1. 0.5 is the center position.
|
||||
* @const {number}
|
||||
*/
|
||||
const CENTER_Y = 0.5;
|
||||
|
||||
/**
|
||||
* Reused memory location for storing an HSV color value.
|
||||
* @type {Array<number>}
|
||||
*/
|
||||
const __hsv = [0, 0, 0];
|
||||
|
||||
class EffectTransform {
|
||||
|
||||
/**
|
||||
* Transform a color in-place given the drawable's effect uniforms. Will apply
|
||||
* Ghost and Color and Brightness effects.
|
||||
* @param {Drawable} drawable The drawable to get uniforms from.
|
||||
* @param {Uint8ClampedArray} inOutColor The color to transform.
|
||||
* @param {number} [effectMask] A bitmask for which effects to use. Optional.
|
||||
* @returns {Uint8ClampedArray} dst filled with the transformed color
|
||||
*/
|
||||
static transformColor (drawable, inOutColor, effectMask) {
|
||||
// If the color is fully transparent, don't bother attempting any transformations.
|
||||
if (inOutColor[3] === 0) {
|
||||
return inOutColor;
|
||||
}
|
||||
|
||||
let effects = drawable.enabledEffects;
|
||||
if (typeof effectMask === 'number') effects &= effectMask;
|
||||
const uniforms = drawable.getUniforms();
|
||||
|
||||
const enableColor = (effects & ShaderManager.EFFECT_INFO.color.mask) !== 0;
|
||||
const enableBrightness = (effects & ShaderManager.EFFECT_INFO.brightness.mask) !== 0;
|
||||
|
||||
if (enableColor || enableBrightness) {
|
||||
// gl_FragColor.rgb /= gl_FragColor.a + epsilon;
|
||||
// Here, we're dividing by the (previously pre-multiplied) alpha to ensure HSV is properly calculated
|
||||
// for partially transparent pixels.
|
||||
// epsilon is present in the shader because dividing by 0 (fully transparent pixels) messes up calculations.
|
||||
// We're doing this with a Uint8ClampedArray here, so dividing by 0 just gives 255. We're later multiplying
|
||||
// by 0 again, so it won't affect results.
|
||||
const alpha = inOutColor[3] / 255;
|
||||
inOutColor[0] /= alpha;
|
||||
inOutColor[1] /= alpha;
|
||||
inOutColor[2] /= alpha;
|
||||
|
||||
if (enableColor) {
|
||||
// vec3 hsv = convertRGB2HSV(gl_FragColor.xyz);
|
||||
const hsv = rgbToHsv(inOutColor, __hsv);
|
||||
|
||||
// this code forces grayscale values to be slightly saturated
|
||||
// so that some slight change of hue will be visible
|
||||
// const float minLightness = 0.11 / 2.0;
|
||||
const minV = 0.11 / 2.0;
|
||||
// const float minSaturation = 0.09;
|
||||
const minS = 0.09;
|
||||
// if (hsv.z < minLightness) hsv = vec3(0.0, 1.0, minLightness);
|
||||
if (hsv[2] < minV) {
|
||||
hsv[0] = 0;
|
||||
hsv[1] = 1;
|
||||
hsv[2] = minV;
|
||||
// else if (hsv.y < minSaturation) hsv = vec3(0.0, minSaturation, hsv.z);
|
||||
} else if (hsv[1] < minS) {
|
||||
hsv[0] = 0;
|
||||
hsv[1] = minS;
|
||||
}
|
||||
|
||||
// hsv.x = mod(hsv.x + u_color, 1.0);
|
||||
// if (hsv.x < 0.0) hsv.x += 1.0;
|
||||
hsv[0] = (uniforms.u_color + hsv[0] + 1);
|
||||
|
||||
// gl_FragColor.rgb = convertHSV2RGB(hsl);
|
||||
hsvToRgb(hsv, inOutColor);
|
||||
}
|
||||
|
||||
if (enableBrightness) {
|
||||
const brightness = uniforms.u_brightness * 255;
|
||||
// gl_FragColor.rgb = clamp(gl_FragColor.rgb + vec3(u_brightness), vec3(0), vec3(1));
|
||||
// We don't need to clamp because the Uint8ClampedArray does that for us
|
||||
inOutColor[0] += brightness;
|
||||
inOutColor[1] += brightness;
|
||||
inOutColor[2] += brightness;
|
||||
}
|
||||
|
||||
// gl_FragColor.rgb *= gl_FragColor.a + epsilon;
|
||||
// Now we're doing the reverse, premultiplying by the alpha once again.
|
||||
inOutColor[0] *= alpha;
|
||||
inOutColor[1] *= alpha;
|
||||
inOutColor[2] *= alpha;
|
||||
}
|
||||
|
||||
if ((effects & ShaderManager.EFFECT_INFO.ghost.mask) !== 0) {
|
||||
// gl_FragColor *= u_ghost
|
||||
inOutColor[0] *= uniforms.u_ghost;
|
||||
inOutColor[1] *= uniforms.u_ghost;
|
||||
inOutColor[2] *= uniforms.u_ghost;
|
||||
inOutColor[3] *= uniforms.u_ghost;
|
||||
}
|
||||
|
||||
return inOutColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a texture coordinate to one that would be select after applying shader effects.
|
||||
* @param {Drawable} drawable The drawable whose effects to emulate.
|
||||
* @param {twgl.v3} vec The texture coordinate to transform.
|
||||
* @param {twgl.v3} dst A place to store the output coordinate.
|
||||
* @return {twgl.v3} dst - The coordinate after being transform by effects.
|
||||
*/
|
||||
static transformPoint (drawable, vec, dst) {
|
||||
twgl.v3.copy(vec, dst);
|
||||
|
||||
const effects = drawable.enabledEffects;
|
||||
const uniforms = drawable.getUniforms();
|
||||
if ((effects & ShaderManager.EFFECT_INFO.mosaic.mask) !== 0) {
|
||||
// texcoord0 = fract(u_mosaic * texcoord0);
|
||||
dst[0] = uniforms.u_mosaic * dst[0] % 1;
|
||||
dst[1] = uniforms.u_mosaic * dst[1] % 1;
|
||||
}
|
||||
if ((effects & ShaderManager.EFFECT_INFO.pixelate.mask) !== 0) {
|
||||
const skinUniforms = drawable.skin.getUniforms();
|
||||
// vec2 pixelTexelSize = u_skinSize / u_pixelate;
|
||||
const texelX = skinUniforms.u_skinSize[0] / uniforms.u_pixelate;
|
||||
const texelY = skinUniforms.u_skinSize[1] / uniforms.u_pixelate;
|
||||
// texcoord0 = (floor(texcoord0 * pixelTexelSize) + kCenter) /
|
||||
// pixelTexelSize;
|
||||
dst[0] = (Math.floor(dst[0] * texelX) + CENTER_X) / texelX;
|
||||
dst[1] = (Math.floor(dst[1] * texelY) + CENTER_Y) / texelY;
|
||||
}
|
||||
if ((effects & ShaderManager.EFFECT_INFO.whirl.mask) !== 0) {
|
||||
// const float kRadius = 0.5;
|
||||
const RADIUS = 0.5;
|
||||
// vec2 offset = texcoord0 - kCenter;
|
||||
const offsetX = dst[0] - CENTER_X;
|
||||
const offsetY = dst[1] - CENTER_Y;
|
||||
// float offsetMagnitude = length(offset);
|
||||
const offsetMagnitude = Math.sqrt(Math.pow(offsetX, 2) + Math.pow(offsetY, 2));
|
||||
// float whirlFactor = max(1.0 - (offsetMagnitude / kRadius), 0.0);
|
||||
const whirlFactor = Math.max(1.0 - (offsetMagnitude / RADIUS), 0.0);
|
||||
// float whirlActual = u_whirl * whirlFactor * whirlFactor;
|
||||
const whirlActual = uniforms.u_whirl * whirlFactor * whirlFactor;
|
||||
// float sinWhirl = sin(whirlActual);
|
||||
const sinWhirl = Math.sin(whirlActual);
|
||||
// float cosWhirl = cos(whirlActual);
|
||||
const cosWhirl = Math.cos(whirlActual);
|
||||
// mat2 rotationMatrix = mat2(
|
||||
// cosWhirl, -sinWhirl,
|
||||
// sinWhirl, cosWhirl
|
||||
// );
|
||||
const rot1 = cosWhirl;
|
||||
const rot2 = -sinWhirl;
|
||||
const rot3 = sinWhirl;
|
||||
const rot4 = cosWhirl;
|
||||
|
||||
// texcoord0 = rotationMatrix * offset + kCenter;
|
||||
dst[0] = (rot1 * offsetX) + (rot3 * offsetY) + CENTER_X;
|
||||
dst[1] = (rot2 * offsetX) + (rot4 * offsetY) + CENTER_Y;
|
||||
}
|
||||
if ((effects & ShaderManager.EFFECT_INFO.fisheye.mask) !== 0) {
|
||||
// vec2 vec = (texcoord0 - kCenter) / kCenter;
|
||||
const vX = (dst[0] - CENTER_X) / CENTER_X;
|
||||
const vY = (dst[1] - CENTER_Y) / CENTER_Y;
|
||||
// float vecLength = length(vec);
|
||||
const vLength = Math.sqrt((vX * vX) + (vY * vY));
|
||||
// float r = pow(min(vecLength, 1.0), u_fisheye) * max(1.0, vecLength);
|
||||
const r = Math.pow(Math.min(vLength, 1), uniforms.u_fisheye) * Math.max(1, vLength);
|
||||
// vec2 unit = vec / vecLength;
|
||||
const unitX = vX / vLength;
|
||||
const unitY = vY / vLength;
|
||||
// texcoord0 = kCenter + r * unit * kCenter;
|
||||
dst[0] = CENTER_X + (r * unitX * CENTER_X);
|
||||
dst[1] = CENTER_Y + (r * unitY * CENTER_Y);
|
||||
}
|
||||
|
||||
return dst;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EffectTransform;
|
||||
565
scratch-render/src/PenSkin.js
Normal file
565
scratch-render/src/PenSkin.js
Normal file
@@ -0,0 +1,565 @@
|
||||
const twgl = require('twgl.js');
|
||||
|
||||
const RenderConstants = require('./RenderConstants');
|
||||
const Skin = require('./Skin');
|
||||
|
||||
const ShaderManager = require('./ShaderManager');
|
||||
|
||||
/**
|
||||
* Attributes to use when drawing with the pen
|
||||
* @typedef {object} PenSkin#PenAttributes
|
||||
* @property {number} [diameter] - The size (diameter) of the pen.
|
||||
* @property {Array<number>} [color4f] - The pen color as an array of [r,g,b,a], each component in the range [0,1].
|
||||
*/
|
||||
|
||||
/**
|
||||
* The pen attributes to use when unspecified.
|
||||
* @type {PenSkin#PenAttributes}
|
||||
* @memberof PenSkin
|
||||
* @private
|
||||
* @const
|
||||
*/
|
||||
const DefaultPenAttributes = {
|
||||
color4f: [0, 0, 1, 1],
|
||||
diameter: 1
|
||||
};
|
||||
|
||||
const PEN_ATTRIBUTE_BUFFER_SIZE = 163800;
|
||||
const PEN_ATTRIBUTE_STRIDE = 10;
|
||||
const PEN_ATTRIBUTE_STRIDE_BYTES = PEN_ATTRIBUTE_STRIDE * 4;
|
||||
|
||||
class PenSkin extends Skin {
|
||||
/**
|
||||
* Create a Skin which implements a Scratch pen layer.
|
||||
* @param {int} id - The unique ID for this Skin.
|
||||
* @param {RenderWebGL} renderer - The renderer which will use this Skin.
|
||||
* @extends Skin
|
||||
* @listens RenderWebGL#event:NativeSizeChanged
|
||||
*/
|
||||
constructor (id, renderer) {
|
||||
super(id, renderer);
|
||||
|
||||
/** @type {Array<number>} */
|
||||
this._size = null;
|
||||
|
||||
/** @type {WebGLFramebuffer} */
|
||||
this._framebuffer = null;
|
||||
|
||||
/** @type {boolean} */
|
||||
this._silhouetteDirty = false;
|
||||
|
||||
/** @type {Uint8Array} */
|
||||
this._silhouettePixels = null;
|
||||
|
||||
/** @type {ImageData} */
|
||||
this._silhouetteImageData = null;
|
||||
|
||||
/** @type {object} */
|
||||
this._lineOnBufferDrawRegionId = {
|
||||
enter: () => this._enterDrawLineOnBuffer(),
|
||||
exit: () => this._exitDrawLineOnBuffer()
|
||||
};
|
||||
|
||||
/** @type {object} */
|
||||
this._usePenBufferDrawRegionId = {
|
||||
enter: () => this._enterUsePenBuffer(),
|
||||
exit: () => this._exitUsePenBuffer()
|
||||
};
|
||||
|
||||
/** @type {WebGLRenderingContext} */
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
// tw: renderQuality attribute
|
||||
this.renderQuality = 1;
|
||||
|
||||
// tw: keep track of native size
|
||||
this._nativeSize = renderer.getNativeSize();
|
||||
|
||||
const NO_EFFECTS = 0;
|
||||
/** @type {twgl.ProgramInfo} */
|
||||
this._lineShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.line, NO_EFFECTS);
|
||||
|
||||
// Draw region used to preserve texture when resizing
|
||||
this._drawTextureShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.default, NO_EFFECTS);
|
||||
/** @type {object} */
|
||||
this._drawTextureRegionId = {
|
||||
enter: () => this._enterDrawTexture(),
|
||||
exit: () => this._exitDrawTexture()
|
||||
};
|
||||
|
||||
this.a_position_glbuffer = gl.createBuffer();
|
||||
this.a_position_loc = gl.getAttribLocation(this._lineShader.program, 'a_position');
|
||||
|
||||
this.a_lineColor_loc = gl.getAttribLocation(this._lineShader.program, 'a_lineColor');
|
||||
this.a_lineThicknessAndLength_loc = gl.getAttribLocation(this._lineShader.program, 'a_lineThicknessAndLength');
|
||||
this.a_penPoints_loc = gl.getAttribLocation(this._lineShader.program, 'a_penPoints');
|
||||
|
||||
this.attribute_glbuffer = gl.createBuffer();
|
||||
this.attribute_index = 0;
|
||||
this.attribute_data = new Float32Array(PEN_ATTRIBUTE_BUFFER_SIZE);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.attribute_glbuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this.attribute_data.length * 4, gl.STREAM_DRAW);
|
||||
|
||||
if (gl.drawArraysInstanced) {
|
||||
// WebGL2 has native instanced rendering
|
||||
this.instancedRendering = true;
|
||||
this.glDrawArraysInstanced = gl.drawArraysInstanced.bind(gl);
|
||||
this.glVertexAttribDivisor = gl.vertexAttribDivisor.bind(gl);
|
||||
} else {
|
||||
// WebGL1 may have instanced rendering through the ANGLE_instanced_arrays extension
|
||||
const instancedArraysExtension = gl.getExtension('ANGLE_instanced_arrays');
|
||||
if (instancedArraysExtension) {
|
||||
this.instancedRendering = true;
|
||||
this.glDrawArraysInstanced = instancedArraysExtension.drawArraysInstancedANGLE.bind(
|
||||
instancedArraysExtension
|
||||
);
|
||||
this.glVertexAttribDivisor = instancedArraysExtension.vertexAttribDivisorANGLE.bind(
|
||||
instancedArraysExtension
|
||||
);
|
||||
} else {
|
||||
// Inefficient but still supported
|
||||
this.instancedRendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.instancedRendering) {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.a_position_glbuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
1, 0,
|
||||
0, 0,
|
||||
1, 1,
|
||||
0, 1
|
||||
]), gl.STATIC_DRAW);
|
||||
} else {
|
||||
const positionBuffer = new Float32Array(PEN_ATTRIBUTE_BUFFER_SIZE / PEN_ATTRIBUTE_STRIDE * 2);
|
||||
for (let i = 0; i < positionBuffer.length; i += 12) {
|
||||
positionBuffer[i + 0] = 1;
|
||||
positionBuffer[i + 1] = 0;
|
||||
positionBuffer[i + 2] = 0;
|
||||
positionBuffer[i + 3] = 0;
|
||||
positionBuffer[i + 4] = 1;
|
||||
positionBuffer[i + 5] = 1;
|
||||
positionBuffer[i + 6] = 1;
|
||||
positionBuffer[i + 7] = 1;
|
||||
positionBuffer[i + 8] = 0;
|
||||
positionBuffer[i + 9] = 0;
|
||||
positionBuffer[i + 10] = 0;
|
||||
positionBuffer[i + 11] = 1;
|
||||
}
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.a_position_glbuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, positionBuffer, gl.STATIC_DRAW);
|
||||
}
|
||||
|
||||
this.onNativeSizeChanged = this.onNativeSizeChanged.bind(this);
|
||||
this._renderer.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
|
||||
|
||||
this._setCanvasSize(renderer.getNativeSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of this object. Do not use it after calling this method.
|
||||
*/
|
||||
dispose () {
|
||||
this._renderer.removeListener(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
|
||||
this._renderer.gl.deleteTexture(this._texture);
|
||||
this._texture = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<number>} the "native" size, in texels, of this skin. [width, height]
|
||||
*/
|
||||
get size () {
|
||||
// tw: use native size for Drawable positioning logic
|
||||
return this._nativeSize;
|
||||
}
|
||||
|
||||
useNearest (scale) {
|
||||
// Use nearest-neighbor interpolation when scaling up the pen skin-- this matches Scratch 2.0.
|
||||
// When scaling it down, use linear interpolation to avoid giving pen lines a "dashed" appearance.
|
||||
return Math.max(scale[0], scale[1]) >= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<number>} scale The X and Y scaling factors to be used, as percentages of this skin's "native" size.
|
||||
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given size.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
getTexture (scale) {
|
||||
return this._texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pen layer.
|
||||
*/
|
||||
clear () {
|
||||
this._renderer.enterDrawRegion(this._usePenBufferDrawRegionId);
|
||||
|
||||
/* Reset framebuffer to transparent black */
|
||||
const gl = this._renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
this._silhouetteDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a point on the pen layer.
|
||||
* @param {PenAttributes} penAttributes - how the point should be drawn.
|
||||
* @param {number} x - the X coordinate of the point to draw.
|
||||
* @param {number} y - the Y coordinate of the point to draw.
|
||||
*/
|
||||
drawPoint (penAttributes, x, y) {
|
||||
this.drawLine(penAttributes, x, y, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line on the pen layer.
|
||||
* @param {PenAttributes} penAttributes - how the line should be drawn.
|
||||
* @param {number} x0 - the X coordinate of the beginning of the line.
|
||||
* @param {number} y0 - the Y coordinate of the beginning of the line.
|
||||
* @param {number} x1 - the X coordinate of the end of the line.
|
||||
* @param {number} y1 - the Y coordinate of the end of the line.
|
||||
*/
|
||||
drawLine (penAttributes, x0, y0, x1, y1) {
|
||||
// For compatibility with Scratch 2.0, offset pen lines of width 1 and 3 so they're pixel-aligned.
|
||||
// See https://github.com/LLK/scratch-render/pull/314
|
||||
const diameter = penAttributes.diameter || DefaultPenAttributes.diameter;
|
||||
const offset = (diameter === 1 || diameter === 3) ? 0.5 : 0;
|
||||
|
||||
this._drawLineOnBuffer(
|
||||
penAttributes,
|
||||
x0 + offset, y0 + offset,
|
||||
x1 + offset, y1 + offset
|
||||
);
|
||||
|
||||
this._silhouetteDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare to draw lines in the _lineOnBufferDrawRegionId region.
|
||||
*/
|
||||
_enterDrawLineOnBuffer () {
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
twgl.bindFramebufferInfo(gl, this._framebuffer);
|
||||
|
||||
gl.viewport(0, 0, this._size[0], this._size[1]);
|
||||
|
||||
const currentShader = this._lineShader;
|
||||
gl.useProgram(currentShader.program);
|
||||
|
||||
const uniforms = {
|
||||
u_skin: this._texture,
|
||||
u_stageSize: this._size
|
||||
};
|
||||
|
||||
twgl.setUniforms(currentShader, uniforms);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.a_position_glbuffer);
|
||||
gl.enableVertexAttribArray(this.a_position_loc);
|
||||
gl.vertexAttribPointer(this.a_position_loc, 2, gl.FLOAT, false, 2 * 4, 0);
|
||||
|
||||
this.attribute_index = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return to a base state from _lineOnBufferDrawRegionId.
|
||||
*/
|
||||
_exitDrawLineOnBuffer () {
|
||||
// tw: flush when exiting pen rendering
|
||||
if (this.attribute_index) {
|
||||
this._flushLines();
|
||||
}
|
||||
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
twgl.bindFramebufferInfo(gl, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare to do things with this PenSkin's framebuffer
|
||||
*/
|
||||
_enterUsePenBuffer () {
|
||||
twgl.bindFramebufferInfo(this._renderer.gl, this._framebuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return to a base state
|
||||
*/
|
||||
_exitUsePenBuffer () {
|
||||
twgl.bindFramebufferInfo(this._renderer.gl, null);
|
||||
}
|
||||
|
||||
// tw: draw region used to preserve texture when resizing
|
||||
_enterDrawTexture () {
|
||||
this._enterUsePenBuffer();
|
||||
const gl = this._renderer.gl;
|
||||
gl.viewport(0, 0, this._size[0], this._size[1]);
|
||||
gl.useProgram(this._drawTextureShader.program);
|
||||
twgl.setBuffersAndAttributes(gl, this._drawTextureShader, this._renderer._bufferInfo);
|
||||
}
|
||||
_exitDrawTexture () {
|
||||
this._exitUsePenBuffer();
|
||||
}
|
||||
_drawPenTexture (texture) {
|
||||
this._renderer.enterDrawRegion(this._drawTextureRegionId);
|
||||
const gl = this._renderer.gl;
|
||||
const width = this._size[0];
|
||||
const height = this._size[1];
|
||||
|
||||
const uniforms = {
|
||||
u_skin: texture,
|
||||
u_projectionMatrix: twgl.m4.ortho(
|
||||
width / 2,
|
||||
width / -2,
|
||||
height / -2,
|
||||
height / 2,
|
||||
-1,
|
||||
1,
|
||||
twgl.m4.identity()
|
||||
),
|
||||
u_modelMatrix: twgl.m4.scaling(twgl.v3.create(
|
||||
width,
|
||||
height,
|
||||
0
|
||||
), twgl.m4.identity())
|
||||
};
|
||||
|
||||
twgl.setTextureParameters(gl, texture, {
|
||||
// Always use NEAREST because this most closely matches Scratch behavior
|
||||
minMag: gl.NEAREST
|
||||
});
|
||||
twgl.setUniforms(this._drawTextureShader, uniforms);
|
||||
twgl.drawBufferInfo(gl, this._renderer._bufferInfo, gl.TRIANGLES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line on the framebuffer.
|
||||
* Note that the point coordinates are in the following coordinate space:
|
||||
* +y is down, (0, 0) is the center, and the coords range from (-width / 2, -height / 2) to (height / 2, width / 2).
|
||||
* @param {PenAttributes} penAttributes - how the line should be drawn.
|
||||
* @param {number} x0 - the X coordinate of the beginning of the line.
|
||||
* @param {number} y0 - the Y coordinate of the beginning of the line.
|
||||
* @param {number} x1 - the X coordinate of the end of the line.
|
||||
* @param {number} y1 - the Y coordinate of the end of the line.
|
||||
*/
|
||||
_drawLineOnBuffer (penAttributes, x0, y0, x1, y1) {
|
||||
this._renderer.enterDrawRegion(this._lineOnBufferDrawRegionId);
|
||||
|
||||
const iters = this.instancedRendering ? 1 : 6;
|
||||
|
||||
// For some reason, looking up the size of a buffer through .length can be slow,
|
||||
// so use a constant instead.
|
||||
if (this.attribute_index + (PEN_ATTRIBUTE_STRIDE * iters) > PEN_ATTRIBUTE_BUFFER_SIZE) {
|
||||
this._flushLines();
|
||||
}
|
||||
|
||||
const penColor = penAttributes.color4f || DefaultPenAttributes.color4f;
|
||||
|
||||
// tw: apply renderQuality
|
||||
x0 *= this.renderQuality;
|
||||
y0 *= this.renderQuality;
|
||||
x1 *= this.renderQuality;
|
||||
y1 *= this.renderQuality;
|
||||
|
||||
// Fun fact: Doing this calculation in the shader has the potential to overflow the floating-point range.
|
||||
// 'mediump' precision is only required to have a range up to 2^14 (16384), so any lines longer than 2^7 (128)
|
||||
// can overflow that, because you're squaring the operands, and they could end up as "infinity".
|
||||
// Even GLSL's `length` function won't save us here:
|
||||
// https://asawicki.info/news_1596_watch_out_for_reduced_precision_normalizelength_in_opengl_es
|
||||
const lineDiffX = x1 - x0;
|
||||
const lineDiffY = y1 - y0;
|
||||
const lineLength = Math.sqrt((lineDiffX * lineDiffX) + (lineDiffY * lineDiffY));
|
||||
|
||||
// tw: apply renderQuality
|
||||
const lineThickness = (penAttributes.diameter || DefaultPenAttributes.diameter) * this.renderQuality;
|
||||
|
||||
for (let i = 0; i < iters; i++) {
|
||||
// Pen color sent to the GPU is pre-multiplied by transparency
|
||||
this.attribute_data[this.attribute_index] = penColor[0] * penColor[3];
|
||||
this.attribute_index++;
|
||||
this.attribute_data[this.attribute_index] = penColor[1] * penColor[3];
|
||||
this.attribute_index++;
|
||||
this.attribute_data[this.attribute_index] = penColor[2] * penColor[3];
|
||||
this.attribute_index++;
|
||||
this.attribute_data[this.attribute_index] = penColor[3];
|
||||
this.attribute_index++;
|
||||
|
||||
this.attribute_data[this.attribute_index] = lineThickness;
|
||||
this.attribute_index++;
|
||||
|
||||
this.attribute_data[this.attribute_index] = lineLength;
|
||||
this.attribute_index++;
|
||||
|
||||
this.attribute_data[this.attribute_index] = x0;
|
||||
this.attribute_index++;
|
||||
this.attribute_data[this.attribute_index] = -y0;
|
||||
this.attribute_index++;
|
||||
this.attribute_data[this.attribute_index] = lineDiffX;
|
||||
this.attribute_index++;
|
||||
this.attribute_data[this.attribute_index] = -lineDiffY;
|
||||
this.attribute_index++;
|
||||
}
|
||||
}
|
||||
|
||||
_flushLines () {
|
||||
/** @type {WebGLRenderingContext} */
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.attribute_glbuffer);
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array(this.attribute_data.buffer, 0, this.attribute_index));
|
||||
|
||||
gl.enableVertexAttribArray(this.a_lineColor_loc);
|
||||
gl.vertexAttribPointer(
|
||||
this.a_lineColor_loc,
|
||||
4, gl.FLOAT, false,
|
||||
PEN_ATTRIBUTE_STRIDE_BYTES, 0
|
||||
);
|
||||
|
||||
gl.enableVertexAttribArray(this.a_lineThicknessAndLength_loc);
|
||||
gl.vertexAttribPointer(
|
||||
this.a_lineThicknessAndLength_loc,
|
||||
2, gl.FLOAT, false,
|
||||
PEN_ATTRIBUTE_STRIDE_BYTES, 4 * 4
|
||||
);
|
||||
|
||||
gl.enableVertexAttribArray(this.a_penPoints_loc);
|
||||
gl.vertexAttribPointer(
|
||||
this.a_penPoints_loc,
|
||||
4, gl.FLOAT, false,
|
||||
PEN_ATTRIBUTE_STRIDE_BYTES, 6 * 4
|
||||
);
|
||||
|
||||
if (this.instancedRendering) {
|
||||
this.glVertexAttribDivisor(this.a_lineColor_loc, 1);
|
||||
this.glVertexAttribDivisor(this.a_lineThicknessAndLength_loc, 1);
|
||||
this.glVertexAttribDivisor(this.a_penPoints_loc, 1);
|
||||
|
||||
this.glDrawArraysInstanced(
|
||||
gl.TRIANGLE_STRIP,
|
||||
0, 4,
|
||||
this.attribute_index / PEN_ATTRIBUTE_STRIDE
|
||||
);
|
||||
|
||||
this.glVertexAttribDivisor(this.a_lineColor_loc, 0);
|
||||
this.glVertexAttribDivisor(this.a_lineThicknessAndLength_loc, 0);
|
||||
this.glVertexAttribDivisor(this.a_penPoints_loc, 0);
|
||||
} else {
|
||||
gl.drawArrays(gl.TRIANGLES, 0, this.attribute_index / PEN_ATTRIBUTE_STRIDE);
|
||||
}
|
||||
|
||||
this.attribute_index = 0;
|
||||
this._silhouetteDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a change in the renderer's native size.
|
||||
* @param {object} event - The change event.
|
||||
*/
|
||||
onNativeSizeChanged (event) {
|
||||
// tw: keep track of native size
|
||||
this._nativeSize = event.newSize;
|
||||
this._setCanvasSize([
|
||||
event.newSize[0] * this.renderQuality,
|
||||
event.newSize[1] * this.renderQuality
|
||||
]);
|
||||
this.emitWasAltered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the size of the pen canvas.
|
||||
* @param {Array<int>} canvasSize - the new width and height for the canvas.
|
||||
* @private
|
||||
*/
|
||||
_setCanvasSize (canvasSize) {
|
||||
const [width, height] = canvasSize;
|
||||
|
||||
// tw: do not resize if new size === old size
|
||||
if (this._size && this._size[0] === width && this._size[1] === height) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._size = canvasSize;
|
||||
// tw: use native size for Drawable positioning logic
|
||||
this._rotationCenter[0] = this._nativeSize[0] / 2;
|
||||
this._rotationCenter[1] = this._nativeSize[1] / 2;
|
||||
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
// tw: store current texture to redraw it later
|
||||
const oldTexture = this._texture;
|
||||
|
||||
this._texture = twgl.createTexture(
|
||||
gl,
|
||||
{
|
||||
mag: gl.NEAREST,
|
||||
min: gl.NEAREST,
|
||||
wrap: gl.CLAMP_TO_EDGE,
|
||||
width,
|
||||
height
|
||||
}
|
||||
);
|
||||
|
||||
const attachments = [
|
||||
{
|
||||
format: gl.RGBA,
|
||||
attachment: this._texture
|
||||
}
|
||||
];
|
||||
|
||||
if (this._framebuffer) {
|
||||
// tw: resize framebuffer info doesn't work here, so always make a new framebuffer
|
||||
// twgl.resizeFramebufferInfo(gl, this._framebuffer, attachments, width, height);
|
||||
this._framebuffer = twgl.createFramebufferInfo(gl, attachments, width, height);
|
||||
} else {
|
||||
this._framebuffer = twgl.createFramebufferInfo(gl, attachments, width, height);
|
||||
}
|
||||
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// tw: preserve old texture when resizing
|
||||
if (oldTexture) {
|
||||
this._drawPenTexture(oldTexture);
|
||||
}
|
||||
|
||||
this._silhouettePixels = new Uint8Array(Math.floor(width * height * 4));
|
||||
this._silhouetteImageData = new ImageData(width, height);
|
||||
|
||||
this._silhouetteDirty = true;
|
||||
}
|
||||
|
||||
// tw: sets the "quality" of the pen skin
|
||||
setRenderQuality (quality) {
|
||||
if (this.renderQuality === quality) {
|
||||
return;
|
||||
}
|
||||
this.renderQuality = quality;
|
||||
this._setCanvasSize([Math.round(this._nativeSize[0] * quality), Math.round(this._nativeSize[1] * quality)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* If there have been pen operations that have dirtied the canvas, update
|
||||
* now before someone wants to use our silhouette.
|
||||
*/
|
||||
updateSilhouette () {
|
||||
if (this._silhouetteDirty) {
|
||||
this._renderer.enterDrawRegion(this._usePenBufferDrawRegionId);
|
||||
// Sample the framebuffer's pixels into the silhouette instance
|
||||
const gl = this._renderer.gl;
|
||||
gl.readPixels(
|
||||
0, 0,
|
||||
this._size[0], this._size[1],
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, this._silhouettePixels
|
||||
);
|
||||
|
||||
this._silhouetteImageData.data.set(this._silhouettePixels);
|
||||
this._silhouette.update(this._silhouetteImageData, true /* isPremultiplied */);
|
||||
|
||||
this._silhouetteDirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PenSkin;
|
||||
196
scratch-render/src/Rectangle.js
Normal file
196
scratch-render/src/Rectangle.js
Normal file
@@ -0,0 +1,196 @@
|
||||
class Rectangle {
|
||||
/**
|
||||
* A utility for creating and comparing axis-aligned rectangles.
|
||||
* Rectangles are always initialized to the "largest possible rectangle";
|
||||
* use one of the init* methods below to set up a particular rectangle.
|
||||
* @constructor
|
||||
*/
|
||||
constructor () {
|
||||
this.left = -Infinity;
|
||||
this.right = Infinity;
|
||||
this.bottom = -Infinity;
|
||||
this.top = Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a Rectangle from given Scratch-coordinate bounds.
|
||||
* @param {number} left Left bound of the rectangle.
|
||||
* @param {number} right Right bound of the rectangle.
|
||||
* @param {number} bottom Bottom bound of the rectangle.
|
||||
* @param {number} top Top bound of the rectangle.
|
||||
*/
|
||||
initFromBounds (left, right, bottom, top) {
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.bottom = bottom;
|
||||
this.top = top;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a Rectangle to the minimum AABB around a set of points.
|
||||
* @param {Array<Array<number>>} points Array of [x, y] points.
|
||||
*/
|
||||
initFromPointsAABB (points) {
|
||||
this.left = Infinity;
|
||||
this.right = -Infinity;
|
||||
this.top = -Infinity;
|
||||
this.bottom = Infinity;
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const x = points[i][0];
|
||||
const y = points[i][1];
|
||||
if (x < this.left) {
|
||||
this.left = x;
|
||||
}
|
||||
if (x > this.right) {
|
||||
this.right = x;
|
||||
}
|
||||
if (y > this.top) {
|
||||
this.top = y;
|
||||
}
|
||||
if (y < this.bottom) {
|
||||
this.bottom = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a Rectangle to a 1 unit square centered at 0 x 0 transformed
|
||||
* by a model matrix.
|
||||
* @param {Array.<number>} m A 4x4 matrix to transform the rectangle by.
|
||||
* @tutorial Rectangle-AABB-Matrix
|
||||
*/
|
||||
initFromModelMatrix (m) {
|
||||
// In 2D space, we will soon use the 2x2 "top left" scale and rotation
|
||||
// submatrix, while we store and the 1x2 "top right" that position
|
||||
// vector.
|
||||
const m30 = m[(3 * 4) + 0];
|
||||
const m31 = m[(3 * 4) + 1];
|
||||
|
||||
// "Transform" a (0.5, 0.5) vector by the scale and rotation matrix but
|
||||
// sum the absolute of each component instead of use the signed values.
|
||||
const x = Math.abs(0.5 * m[(0 * 4) + 0]) + Math.abs(0.5 * m[(1 * 4) + 0]);
|
||||
const y = Math.abs(0.5 * m[(0 * 4) + 1]) + Math.abs(0.5 * m[(1 * 4) + 1]);
|
||||
|
||||
// And adding them to the position components initializes our Rectangle.
|
||||
this.left = -x + m30;
|
||||
this.right = x + m30;
|
||||
this.top = y + m31;
|
||||
this.bottom = -y + m31;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if this Rectangle intersects some other.
|
||||
* Note that this is a comparison assuming the Rectangle was
|
||||
* initialized with Scratch-space bounds or points.
|
||||
* @param {!Rectangle} other Rectangle to check if intersecting.
|
||||
* @return {boolean} True if this Rectangle intersects other.
|
||||
*/
|
||||
intersects (other) {
|
||||
return (
|
||||
this.left <= other.right &&
|
||||
other.left <= this.right &&
|
||||
this.top >= other.bottom &&
|
||||
other.top >= this.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if this Rectangle fully contains some other.
|
||||
* Note that this is a comparison assuming the Rectangle was
|
||||
* initialized with Scratch-space bounds or points.
|
||||
* @param {!Rectangle} other Rectangle to check if fully contained.
|
||||
* @return {boolean} True if this Rectangle fully contains other.
|
||||
*/
|
||||
contains (other) {
|
||||
return (
|
||||
other.left > this.left &&
|
||||
other.right < this.right &&
|
||||
other.top < this.top &&
|
||||
other.bottom > this.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a Rectangle to bounds.
|
||||
* @param {number} left Left clamp.
|
||||
* @param {number} right Right clamp.
|
||||
* @param {number} bottom Bottom clamp.
|
||||
* @param {number} top Top clamp.
|
||||
*/
|
||||
clamp (left, right, bottom, top) {
|
||||
this.left = Math.max(this.left, left);
|
||||
this.right = Math.min(this.right, right);
|
||||
this.bottom = Math.max(this.bottom, bottom);
|
||||
this.top = Math.min(this.top, top);
|
||||
|
||||
this.left = Math.min(this.left, right);
|
||||
this.right = Math.max(this.right, left);
|
||||
this.bottom = Math.min(this.bottom, top);
|
||||
this.top = Math.max(this.top, bottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push out the Rectangle to integer bounds.
|
||||
*/
|
||||
snapToInt () {
|
||||
this.left = Math.floor(this.left);
|
||||
this.right = Math.ceil(this.right);
|
||||
this.bottom = Math.floor(this.bottom);
|
||||
this.top = Math.ceil(this.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the intersection of two bounding Rectangles.
|
||||
* Could be an impossible box if they don't intersect.
|
||||
* @param {Rectangle} a One rectangle
|
||||
* @param {Rectangle} b Other rectangle
|
||||
* @param {?Rectangle} result A resulting storage rectangle (safe to pass
|
||||
* a or b if you want to overwrite one)
|
||||
* @returns {Rectangle} resulting rectangle
|
||||
*/
|
||||
static intersect (a, b, result = new Rectangle()) {
|
||||
result.left = Math.max(a.left, b.left);
|
||||
result.right = Math.min(a.right, b.right);
|
||||
result.top = Math.min(a.top, b.top);
|
||||
result.bottom = Math.max(a.bottom, b.bottom);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the union of two bounding Rectangles.
|
||||
* @param {Rectangle} a One rectangle
|
||||
* @param {Rectangle} b Other rectangle
|
||||
* @param {?Rectangle} result A resulting storage rectangle (safe to pass
|
||||
* a or b if you want to overwrite one)
|
||||
* @returns {Rectangle} resulting rectangle
|
||||
*/
|
||||
static union (a, b, result = new Rectangle()) {
|
||||
result.left = Math.min(a.left, b.left);
|
||||
result.right = Math.max(a.right, b.right);
|
||||
// Scratch Space - +y is up
|
||||
result.top = Math.max(a.top, b.top);
|
||||
result.bottom = Math.min(a.bottom, b.bottom);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Width of the Rectangle.
|
||||
* @return {number} Width of rectangle.
|
||||
*/
|
||||
get width () {
|
||||
return Math.abs(this.left - this.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of the Rectangle.
|
||||
* @return {number} Height of rectangle.
|
||||
*/
|
||||
get height () {
|
||||
return Math.abs(this.top - this.bottom);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Rectangle;
|
||||
37
scratch-render/src/RenderConstants.js
Normal file
37
scratch-render/src/RenderConstants.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/** @module RenderConstants */
|
||||
|
||||
/**
|
||||
* Various constants meant for use throughout the renderer.
|
||||
* @enum
|
||||
*/
|
||||
module.exports = {
|
||||
/**
|
||||
* The ID value to use for "no item" or when an object has been disposed.
|
||||
* @const {int}
|
||||
*/
|
||||
ID_NONE: -1,
|
||||
|
||||
/**
|
||||
* @enum {string}
|
||||
*/
|
||||
Events: {
|
||||
/**
|
||||
* Event emitted when the high quality render option changes.
|
||||
*/
|
||||
UseHighQualityRenderChanged: 'UseHighQualityRenderChanged',
|
||||
|
||||
/**
|
||||
* Event emitted when the private skin access option changes.
|
||||
*/
|
||||
AllowPrivateSkinAccessChanged: 'AllowPrivateSkinAccessChanged',
|
||||
|
||||
/**
|
||||
* NativeSizeChanged event
|
||||
*
|
||||
* @event RenderWebGL#event:NativeSizeChanged
|
||||
* @type {object}
|
||||
* @property {Array<int>} newSize - the new size of the renderer
|
||||
*/
|
||||
NativeSizeChanged: 'NativeSizeChanged'
|
||||
}
|
||||
};
|
||||
2389
scratch-render/src/RenderWebGL.js
Normal file
2389
scratch-render/src/RenderWebGL.js
Normal file
File diff suppressed because it is too large
Load Diff
245
scratch-render/src/SVGSkin.js
Normal file
245
scratch-render/src/SVGSkin.js
Normal file
@@ -0,0 +1,245 @@
|
||||
const twgl = require('twgl.js');
|
||||
|
||||
const Skin = require('./Skin');
|
||||
const {loadSvgString, serializeSvgToString} = require('@turbowarp/scratch-svg-renderer');
|
||||
const ShaderManager = require('./ShaderManager');
|
||||
|
||||
/**
|
||||
* All scaled renderings of the SVG are stored in an array. The 1.0 scale of
|
||||
* the SVG is stored at the 8th index. The smallest possible 1 / 256 scale
|
||||
* rendering is stored at the 0th index.
|
||||
* @const {number}
|
||||
*/
|
||||
const INDEX_OFFSET = 8;
|
||||
|
||||
class SVGSkin extends Skin {
|
||||
/**
|
||||
* Create a new SVG skin.
|
||||
* @param {!int} id - The ID for this Skin.
|
||||
* @param {!RenderWebGL} renderer - The renderer which will use this skin.
|
||||
* @constructor
|
||||
* @extends Skin
|
||||
*/
|
||||
constructor (id, renderer) {
|
||||
super(id, renderer);
|
||||
|
||||
/** @type {HTMLImageElement} */
|
||||
this._svgImage = document.createElement('img');
|
||||
|
||||
/** @type {boolean} */
|
||||
this._svgImageLoaded = false;
|
||||
|
||||
/** @type {Array<number>} */
|
||||
this._size = [0, 0];
|
||||
|
||||
/** @type {HTMLCanvasElement} */
|
||||
this._canvas = document.createElement('canvas');
|
||||
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
this._context = this._canvas.getContext('2d');
|
||||
|
||||
/** @type {Array<WebGLTexture>} */
|
||||
this._scaledMIPs = [];
|
||||
|
||||
/** @type {number} */
|
||||
this._largestMIPScale = 0;
|
||||
|
||||
/**
|
||||
* Ratio of the size of the SVG and the max size of the WebGL texture
|
||||
* @type {Number}
|
||||
*/
|
||||
this._maxTextureScale = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of this object. Do not use it after calling this method.
|
||||
*/
|
||||
dispose () {
|
||||
this.resetMIPs();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<number>} the natural size, in Scratch units, of this skin.
|
||||
*/
|
||||
get size () {
|
||||
return [this._size[0], this._size[1]];
|
||||
}
|
||||
|
||||
useNearest (scale, drawable) {
|
||||
// If the effect bits for mosaic, pixelate, whirl, or fisheye are set, use linear
|
||||
if ((drawable.enabledEffects & (
|
||||
ShaderManager.EFFECT_INFO.fisheye.mask |
|
||||
ShaderManager.EFFECT_INFO.whirl.mask |
|
||||
ShaderManager.EFFECT_INFO.pixelate.mask |
|
||||
ShaderManager.EFFECT_INFO.mosaic.mask
|
||||
)) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We can't use nearest neighbor unless we are a multiple of 90 rotation
|
||||
if (drawable._direction % 90 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Because SVG skins' bounding boxes are currently not pixel-aligned, the idea here is to hide blurriness
|
||||
// by using nearest-neighbor scaling if one screen-space pixel is "close enough" to one texture pixel.
|
||||
// If the scale of the skin is very close to 100 (0.99999 variance is okay I guess)
|
||||
// TODO: Make this check more precise. We should use nearest if there's less than one pixel's difference
|
||||
// between the screen-space and texture-space sizes of the skin. Mipmaps make this harder because there are
|
||||
// multiple textures (and hence multiple texture spaces) and we need to know which one to choose.
|
||||
if (Math.abs(scale[0]) > 99 && Math.abs(scale[0]) < 101 &&
|
||||
Math.abs(scale[1]) > 99 && Math.abs(scale[1]) < 101) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MIP for a given scale.
|
||||
* @param {number} scale - The relative size of the MIP
|
||||
* @return {SVGMIP} An object that handles creating and updating SVG textures.
|
||||
*/
|
||||
createMIP (scale) {
|
||||
const isLargestMIP = this._largestMIPScale < scale;
|
||||
// TW: Silhouette will lazily read image data from our <canvas>. However, this canvas is shared
|
||||
// between the Skin and Silhouette so changing it here can mess up Silhouette. To prevent that,
|
||||
// we will force the silhouette to synchronously read the image data before we mutate the
|
||||
// canvas, unless the new MIP is the largest MIP, in which case doing so is unnecessary as we
|
||||
// will update the silhouette later anyways.
|
||||
if (!isLargestMIP) {
|
||||
this._silhouette.unlazy();
|
||||
}
|
||||
|
||||
const [width, height] = this._size;
|
||||
this._canvas.width = width * scale;
|
||||
this._canvas.height = height * scale;
|
||||
if (
|
||||
this._canvas.width <= 0 ||
|
||||
this._canvas.height <= 0 ||
|
||||
// Even if the canvas at the current scale has a nonzero size, the image's dimensions are floored
|
||||
// pre-scaling; e.g. if an image has a width of 0.4 and is being rendered at 3x scale, the canvas will have
|
||||
// a width of 1, but the image's width will be rounded down to 0 on some browsers (Firefox) prior to being
|
||||
// drawn at that scale, resulting in an IndexSizeError if we attempt to draw it.
|
||||
this._svgImage.naturalWidth <= 0 ||
|
||||
this._svgImage.naturalHeight <= 0
|
||||
) return super.getTexture();
|
||||
this._context.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
this._context.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
this._context.drawImage(this._svgImage, 0, 0);
|
||||
|
||||
// TW: Reading image data from <canvas> is very slow and causes animations to stutter,
|
||||
// so we just use the canvas directly instead.
|
||||
const textureData = this._canvas;
|
||||
|
||||
const textureOptions = {
|
||||
auto: false,
|
||||
wrap: this._renderer.gl.CLAMP_TO_EDGE,
|
||||
src: textureData,
|
||||
premultiplyAlpha: true
|
||||
};
|
||||
|
||||
const mip = twgl.createTexture(this._renderer.gl, textureOptions);
|
||||
|
||||
// Check if this is the largest MIP created so far. Currently, silhouettes only get scaled up.
|
||||
if (isLargestMIP) {
|
||||
this._silhouette.update(textureData);
|
||||
this._largestMIPScale = scale;
|
||||
}
|
||||
|
||||
return mip;
|
||||
}
|
||||
|
||||
updateSilhouette (scale = [100, 100]) {
|
||||
// Ensure a silhouette exists.
|
||||
this.getTexture(scale);
|
||||
this._silhouette.unlazy();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<number>} scale - The scaling factors to be used, each in the [0,100] range.
|
||||
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale.
|
||||
*/
|
||||
getTexture (scale) {
|
||||
// The texture only ever gets uniform scale. Take the larger of the two axes.
|
||||
const scaleMax = scale ? Math.max(Math.abs(scale[0]), Math.abs(scale[1])) : 100;
|
||||
const requestedScale = Math.min(scaleMax / 100, this._maxTextureScale);
|
||||
|
||||
// Math.ceil(Math.log2(scale)) means we use the "1x" texture at (0.5, 1] scale,
|
||||
// the "2x" texture at (1, 2] scale, the "4x" texture at (2, 4] scale, etc.
|
||||
// This means that one texture pixel will always be between 0.5x and 1x the size of one rendered pixel,
|
||||
// but never bigger than one rendered pixel--this prevents blurriness from blowing up the texture too much.
|
||||
const mipLevel = Math.max(Math.ceil(Math.log2(requestedScale)) + INDEX_OFFSET, 0);
|
||||
// Can't use bitwise stuff here because we need to handle negative exponents
|
||||
const mipScale = Math.pow(2, mipLevel - INDEX_OFFSET);
|
||||
|
||||
if (this._svgImageLoaded && !this._scaledMIPs[mipLevel]) {
|
||||
this._scaledMIPs[mipLevel] = this.createMIP(mipScale);
|
||||
}
|
||||
|
||||
return this._scaledMIPs[mipLevel] || super.getTexture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Do a hard reset of the existing MIPs by deleting them.
|
||||
*/
|
||||
resetMIPs () {
|
||||
this._scaledMIPs.forEach(oldMIP => this._renderer.gl.deleteTexture(oldMIP));
|
||||
this._scaledMIPs.length = 0;
|
||||
this._largestMIPScale = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the contents of this skin to a snapshot of the provided SVG data.
|
||||
* @param {string} svgData - new SVG to use.
|
||||
* @param {Array<number>} [rotationCenter] - Optional rotation center for the SVG. If not supplied, it will be
|
||||
* calculated from the bounding box
|
||||
* @fires Skin.event:WasAltered
|
||||
*/
|
||||
setSVG (svgData, rotationCenter) {
|
||||
const svgTag = loadSvgString(svgData);
|
||||
const svgText = serializeSvgToString(svgTag, this._renderer.customFonts);
|
||||
this._svgImageLoaded = false;
|
||||
|
||||
const {x, y, width, height} = svgTag.viewBox.baseVal;
|
||||
// While we're setting the size before the image is loaded, this doesn't cause the skin to appear with the wrong
|
||||
// size for a few frames while the new image is loading, because we don't emit the `WasAltered` event, telling
|
||||
// drawables using this skin to update, until the image is loaded.
|
||||
// We need to do this because the VM reads the skin's `size` directly after calling `setSVG`.
|
||||
// TODO: return a Promise so that the VM can read the skin's `size` after the image is loaded.
|
||||
this._size[0] = width;
|
||||
this._size[1] = height;
|
||||
|
||||
// If there is another load already in progress, replace the old onload to effectively cancel the old load
|
||||
this._svgImage.onload = () => {
|
||||
if (width === 0 || height === 0) {
|
||||
super.setEmptyImageData();
|
||||
return;
|
||||
}
|
||||
|
||||
const maxDimension = Math.ceil(Math.max(width, height));
|
||||
const rendererMax = this._renderer.maxTextureDimension;
|
||||
let testScale = 2;
|
||||
for (testScale; maxDimension * testScale <= rendererMax; testScale *= 2) {
|
||||
this._maxTextureScale = testScale;
|
||||
}
|
||||
|
||||
this.resetMIPs();
|
||||
|
||||
if (typeof rotationCenter === 'undefined') rotationCenter = this.calculateRotationCenter();
|
||||
// Compensate for viewbox offset.
|
||||
// See https://github.com/LLK/scratch-render/pull/90.
|
||||
this._rotationCenter[0] = rotationCenter[0] - x;
|
||||
this._rotationCenter[1] = rotationCenter[1] - y;
|
||||
|
||||
this._svgImageLoaded = true;
|
||||
|
||||
this.emitWasAltered();
|
||||
};
|
||||
|
||||
this._svgImage.src = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = SVGSkin;
|
||||
202
scratch-render/src/ShaderManager.js
Normal file
202
scratch-render/src/ShaderManager.js
Normal file
@@ -0,0 +1,202 @@
|
||||
const twgl = require('twgl.js');
|
||||
|
||||
|
||||
class ShaderManager {
|
||||
/**
|
||||
* @param {WebGLRenderingContext} gl WebGL rendering context to create shaders for
|
||||
* @constructor
|
||||
*/
|
||||
constructor (gl) {
|
||||
this._gl = gl;
|
||||
|
||||
/**
|
||||
* The cache of all shaders compiled so far, filled on demand.
|
||||
* @type {Object<ShaderManager.DRAW_MODE, Array<ProgramInfo>>}
|
||||
* @private
|
||||
*/
|
||||
this._shaderCache = {};
|
||||
for (const modeName in ShaderManager.DRAW_MODE) {
|
||||
if (Object.prototype.hasOwnProperty.call(ShaderManager.DRAW_MODE, modeName)) {
|
||||
this._shaderCache[modeName] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the shader for a particular set of active effects.
|
||||
* Build the shader if necessary.
|
||||
* @param {ShaderManager.DRAW_MODE} drawMode Draw normally, silhouette, etc.
|
||||
* @param {int} effectBits Bitmask representing the enabled effects.
|
||||
* @returns {ProgramInfo} The shader's program info.
|
||||
*/
|
||||
getShader (drawMode, effectBits) {
|
||||
const cache = this._shaderCache[drawMode];
|
||||
if (drawMode === ShaderManager.DRAW_MODE.silhouette) {
|
||||
// Silhouette mode isn't affected by these effects.
|
||||
effectBits &= ~(ShaderManager.EFFECT_INFO.color.mask | ShaderManager.EFFECT_INFO.brightness.mask);
|
||||
}
|
||||
let shader = cache[effectBits];
|
||||
if (!shader) {
|
||||
shader = cache[effectBits] = this._buildShader(drawMode, effectBits);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shader for a particular set of active effects.
|
||||
* @param {ShaderManager.DRAW_MODE} drawMode Draw normally, silhouette, etc.
|
||||
* @param {int} effectBits Bitmask representing the enabled effects.
|
||||
* @returns {ProgramInfo} The new shader's program info.
|
||||
* @private
|
||||
*/
|
||||
_buildShader (drawMode, effectBits) {
|
||||
const numEffects = ShaderManager.EFFECTS.length;
|
||||
|
||||
const defines = [
|
||||
`#define DRAW_MODE_${drawMode}`
|
||||
];
|
||||
for (let index = 0; index < numEffects; ++index) {
|
||||
if ((effectBits & (1 << index)) !== 0) {
|
||||
defines.push(`#define ENABLE_${ShaderManager.EFFECTS[index]}`);
|
||||
}
|
||||
}
|
||||
|
||||
const definesText = `${defines.join('\n')}\n`;
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const vsFullText = definesText + require('raw-loader!./shaders/sprite.vert');
|
||||
const fsFullText = definesText + require('raw-loader!./shaders/sprite.frag');
|
||||
/* eslint-enable global-require */
|
||||
|
||||
let errorMessage = null;
|
||||
const onError = newError => {
|
||||
// twgl won't log the error when we provide a custom error callback, so log it ourselves
|
||||
console.error(newError);
|
||||
|
||||
// For the error that we throw, just include the actual error from WebGL, not all the fancy
|
||||
// extras that twgl adds to the error messages.
|
||||
const match = newError.match(/\*\*\* Error compiling shader: ([\s\S]+)/);
|
||||
errorMessage = match ? match[1].trim() : newError;
|
||||
};
|
||||
|
||||
const program = twgl.createProgramInfo(this._gl, [vsFullText, fsFullText], null, null, onError);
|
||||
if (!program) {
|
||||
throw new Error(`Failed to compile shader (mode ${drawMode}, effects ${effectBits}): ${errorMessage}`);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} ShaderManager.Effect
|
||||
* @prop {int} mask - The bit in 'effectBits' representing the effect.
|
||||
* @prop {function} converter - A conversion function which takes a Scratch value (generally in the range
|
||||
* 0..100 or -100..100) and maps it to a value useful to the shader. This
|
||||
* mapping may not be reversible.
|
||||
* @prop {boolean} shapeChanges - Whether the effect could change the drawn shape.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mapping of each effect name to info about that effect.
|
||||
* @enum {ShaderManager.Effect}
|
||||
*/
|
||||
ShaderManager.EFFECT_INFO = {
|
||||
/** Color effect */
|
||||
color: {
|
||||
uniformName: 'u_color',
|
||||
mask: 1 << 0,
|
||||
converter: x => (x / 200) % 1,
|
||||
shapeChanges: false
|
||||
},
|
||||
/** Fisheye effect */
|
||||
fisheye: {
|
||||
uniformName: 'u_fisheye',
|
||||
mask: 1 << 1,
|
||||
converter: x => Math.max(0, (x + 100) / 100),
|
||||
shapeChanges: true
|
||||
},
|
||||
/** Whirl effect */
|
||||
whirl: {
|
||||
uniformName: 'u_whirl',
|
||||
mask: 1 << 2,
|
||||
converter: x => -x * Math.PI / 180,
|
||||
shapeChanges: true
|
||||
},
|
||||
/** Pixelate effect */
|
||||
pixelate: {
|
||||
uniformName: 'u_pixelate',
|
||||
mask: 1 << 3,
|
||||
converter: x => Math.abs(x) / 10,
|
||||
shapeChanges: true
|
||||
},
|
||||
/** Mosaic effect */
|
||||
mosaic: {
|
||||
uniformName: 'u_mosaic',
|
||||
mask: 1 << 4,
|
||||
converter: x => {
|
||||
x = Math.round((Math.abs(x) + 10) / 10);
|
||||
/** @todo cap by Math.min(srcWidth, srcHeight) */
|
||||
return Math.max(1, Math.min(x, 512));
|
||||
},
|
||||
shapeChanges: true
|
||||
},
|
||||
/** Brightness effect */
|
||||
brightness: {
|
||||
uniformName: 'u_brightness',
|
||||
mask: 1 << 5,
|
||||
converter: x => Math.max(-100, Math.min(x, 100)) / 100,
|
||||
shapeChanges: false
|
||||
},
|
||||
/** Ghost effect */
|
||||
ghost: {
|
||||
uniformName: 'u_ghost',
|
||||
mask: 1 << 6,
|
||||
converter: x => 1 - (Math.max(0, Math.min(x, 100)) / 100),
|
||||
shapeChanges: false
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The name of each supported effect.
|
||||
* @type {Array}
|
||||
*/
|
||||
ShaderManager.EFFECTS = Object.keys(ShaderManager.EFFECT_INFO);
|
||||
|
||||
/**
|
||||
* The available draw modes.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
ShaderManager.DRAW_MODE = {
|
||||
/**
|
||||
* Draw normally. Its output will use premultiplied alpha.
|
||||
*/
|
||||
default: 'default',
|
||||
|
||||
/**
|
||||
* Draw with non-premultiplied alpha. Useful for reading pixels from GL into an ImageData object.
|
||||
*/
|
||||
straightAlpha: 'straightAlpha',
|
||||
|
||||
/**
|
||||
* Draw a silhouette using a solid color.
|
||||
*/
|
||||
silhouette: 'silhouette',
|
||||
|
||||
/**
|
||||
* Draw only the parts of the drawable which match a particular color.
|
||||
*/
|
||||
colorMask: 'colorMask',
|
||||
|
||||
/**
|
||||
* Draw a line with caps.
|
||||
*/
|
||||
line: 'line',
|
||||
|
||||
/**
|
||||
* Draw the background in a certain color. Must sometimes be used instead of gl.clear.
|
||||
*/
|
||||
background: 'background'
|
||||
};
|
||||
|
||||
module.exports = ShaderManager;
|
||||
280
scratch-render/src/Silhouette.js
Normal file
280
scratch-render/src/Silhouette.js
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* A representation of a Skin's silhouette that can test if a point on the skin
|
||||
* renders a pixel where it is drawn.
|
||||
*/
|
||||
|
||||
/**
|
||||
* <canvas> element used to update Silhouette data from skin bitmap data.
|
||||
* @type {CanvasElement}
|
||||
*/
|
||||
let __SilhouetteUpdateCanvas;
|
||||
|
||||
// Optimized Math.min and Math.max for integers;
|
||||
// taken from https://web.archive.org/web/20190716181049/http://guihaire.com/code/?p=549
|
||||
const intMin = (i, j) => j ^ ((i ^ j) & ((i - j) >> 31));
|
||||
const intMax = (i, j) => i ^ ((i ^ j) & ((i - j) >> 31));
|
||||
|
||||
/**
|
||||
* Internal helper function (in hopes that compiler can inline). Get a pixel
|
||||
* from silhouette data, or 0 if outside it's bounds.
|
||||
* @private
|
||||
* @param {Silhouette} silhouette - has data width and height
|
||||
* @param {number} x - x
|
||||
* @param {number} y - y
|
||||
* @return {number} Alpha value for x/y position
|
||||
*/
|
||||
const getPoint = ({_width: width, _height: height, _colorData: data}, x, y) => {
|
||||
// 0 if outside bounds, otherwise read from data.
|
||||
if (x >= width || y >= height || x < 0 || y < 0) {
|
||||
return 0;
|
||||
}
|
||||
return data[(((y * width) + x) * 4) + 3];
|
||||
};
|
||||
|
||||
/**
|
||||
* Memory buffers for doing 4 corner sampling for linear interpolation
|
||||
*/
|
||||
const __cornerWork = [
|
||||
new Uint8ClampedArray(4),
|
||||
new Uint8ClampedArray(4),
|
||||
new Uint8ClampedArray(4),
|
||||
new Uint8ClampedArray(4)
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the color from a given silhouette at an x/y local texture position.
|
||||
* Multiply color values by alpha for proper blending.
|
||||
* @param {Silhouette} $0 The silhouette to sample.
|
||||
* @param {number} x X position of texture [0, width).
|
||||
* @param {number} y Y position of texture [0, height).
|
||||
* @param {Uint8ClampedArray} dst A color 4b space.
|
||||
* @return {Uint8ClampedArray} The dst vector.
|
||||
*/
|
||||
const getColor4b = ({_width: width, _height: height, _colorData: data}, x, y, dst) => {
|
||||
// Clamp coords to edge, matching GL_CLAMP_TO_EDGE.
|
||||
// (See github.com/LLK/scratch-render/blob/954cfff02b08069a082cbedd415c1fecd9b1e4fb/src/BitmapSkin.js#L88)
|
||||
x = intMax(0, intMin(x, width - 1));
|
||||
y = intMax(0, intMin(y, height - 1));
|
||||
|
||||
// 0 if outside bounds, otherwise read from data.
|
||||
if (x >= width || y >= height || x < 0 || y < 0) {
|
||||
return dst.fill(0);
|
||||
}
|
||||
const offset = ((y * width) + x) * 4;
|
||||
// premultiply alpha
|
||||
const alpha = data[offset + 3] / 255;
|
||||
dst[0] = data[offset] * alpha;
|
||||
dst[1] = data[offset + 1] * alpha;
|
||||
dst[2] = data[offset + 2] * alpha;
|
||||
dst[3] = data[offset + 3];
|
||||
return dst;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the color from a given silhouette at an x/y local texture position.
|
||||
* Do not multiply color values by alpha, as it has already been done.
|
||||
* @param {Silhouette} $0 The silhouette to sample.
|
||||
* @param {number} x X position of texture [0, width).
|
||||
* @param {number} y Y position of texture [0, height).
|
||||
* @param {Uint8ClampedArray} dst A color 4b space.
|
||||
* @return {Uint8ClampedArray} The dst vector.
|
||||
*/
|
||||
const getPremultipliedColor4b = ({_width: width, _height: height, _colorData: data}, x, y, dst) => {
|
||||
// Clamp coords to edge, matching GL_CLAMP_TO_EDGE.
|
||||
x = intMax(0, intMin(x, width - 1));
|
||||
y = intMax(0, intMin(y, height - 1));
|
||||
|
||||
const offset = ((y * width) + x) * 4;
|
||||
dst[0] = data[offset];
|
||||
dst[1] = data[offset + 1];
|
||||
dst[2] = data[offset + 2];
|
||||
dst[3] = data[offset + 3];
|
||||
return dst;
|
||||
};
|
||||
|
||||
class Silhouette {
|
||||
constructor () {
|
||||
/**
|
||||
* The width of the data representing the current skin data.
|
||||
* @type {number}
|
||||
*/
|
||||
this._width = 0;
|
||||
|
||||
/**
|
||||
* The height of the data representing the current skin date.
|
||||
* @type {number}
|
||||
*/
|
||||
this._height = 0;
|
||||
|
||||
this._lazyData = null;
|
||||
|
||||
/**
|
||||
* The data representing a skin's silhouette shape.
|
||||
* @type {Uint8ClampedArray}
|
||||
*/
|
||||
this._colorData = null;
|
||||
|
||||
// By default, silhouettes are assumed not to contain premultiplied image data,
|
||||
// so when we get a color, we want to multiply it by its alpha channel.
|
||||
// Point `_getColor` to the version of the function that multiplies.
|
||||
this._getColor = getColor4b;
|
||||
|
||||
this.colorAtNearest = this.colorAtLinear = (_, dst) => dst.fill(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this silhouette with the bitmapData for a skin.
|
||||
* @param {ImageData|HTMLCanvasElement|HTMLImageElement} bitmapData An image, canvas or other element that the skin
|
||||
* @param {boolean} isPremultiplied True if the source bitmap data comes premultiplied (e.g. from readPixels).
|
||||
* rendering can be queried from.
|
||||
*/
|
||||
update (bitmapData, isPremultiplied = false) {
|
||||
let imageData;
|
||||
if (bitmapData instanceof ImageData) {
|
||||
// If handed ImageData directly, use it directly.
|
||||
imageData = bitmapData;
|
||||
this._width = bitmapData.width;
|
||||
this._height = bitmapData.height;
|
||||
this._lazyData = null;
|
||||
this._colorData = imageData.data;
|
||||
} else {
|
||||
// TW: No reason to read the image data now, there's a high chance it won't be needed and will
|
||||
// just waste memory and CPU time. We'll read it lazily, only when necessary.
|
||||
this._width = bitmapData.width;
|
||||
this._height = bitmapData.height;
|
||||
if (!(this._width && this._height)) {
|
||||
// TW: It might seem really weird to return here before updating anything else, but this is what
|
||||
// LLK/scratch-render does.
|
||||
return;
|
||||
}
|
||||
this._lazyData = bitmapData;
|
||||
this._colorData = null;
|
||||
}
|
||||
|
||||
if (isPremultiplied) {
|
||||
this._getColor = getPremultipliedColor4b;
|
||||
} else {
|
||||
this._getColor = getColor4b;
|
||||
}
|
||||
|
||||
// delete our custom overriden "uninitalized" color functions
|
||||
// let the prototype work for itself
|
||||
delete this.colorAtNearest;
|
||||
delete this.colorAtLinear;
|
||||
}
|
||||
|
||||
unlazy () {
|
||||
if (!this._lazyData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = this._lazyData.width;
|
||||
const height = this._lazyData.height;
|
||||
if (width && height) {
|
||||
const canvas = Silhouette._updateCanvas();
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.drawImage(this._lazyData, 0, 0, width, height);
|
||||
const textureData = ctx.getImageData(0, 0, width, height);
|
||||
this._colorData = textureData.data;
|
||||
}
|
||||
|
||||
this._lazyData = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a color from the silhouette at a given local position using
|
||||
* "nearest neighbor"
|
||||
* @param {twgl.v3} vec [x,y] texture space (0-1)
|
||||
* @param {Uint8ClampedArray} dst The memory buffer to store the value in. (4 bytes)
|
||||
* @returns {Uint8ClampedArray} dst
|
||||
*/
|
||||
colorAtNearest (vec, dst) {
|
||||
return this._getColor(
|
||||
this,
|
||||
Math.floor(vec[0] * (this._width - 1)),
|
||||
Math.floor(vec[1] * (this._height - 1)),
|
||||
dst
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a color from the silhouette at a given local position using
|
||||
* "linear interpolation"
|
||||
* @param {twgl.v3} vec [x,y] texture space (0-1)
|
||||
* @param {Uint8ClampedArray} dst The memory buffer to store the value in. (4 bytes)
|
||||
* @returns {Uint8ClampedArray} dst
|
||||
*/
|
||||
colorAtLinear (vec, dst) {
|
||||
const x = vec[0] * (this._width - 1);
|
||||
const y = vec[1] * (this._height - 1);
|
||||
|
||||
const x1D = x % 1;
|
||||
const y1D = y % 1;
|
||||
const x0D = 1 - x1D;
|
||||
const y0D = 1 - y1D;
|
||||
|
||||
const xFloor = Math.floor(x);
|
||||
const yFloor = Math.floor(y);
|
||||
|
||||
const x0y0 = this._getColor(this, xFloor, yFloor, __cornerWork[0]);
|
||||
const x1y0 = this._getColor(this, xFloor + 1, yFloor, __cornerWork[1]);
|
||||
const x0y1 = this._getColor(this, xFloor, yFloor + 1, __cornerWork[2]);
|
||||
const x1y1 = this._getColor(this, xFloor + 1, yFloor + 1, __cornerWork[3]);
|
||||
|
||||
dst[0] = (x0y0[0] * x0D * y0D) + (x0y1[0] * x0D * y1D) + (x1y0[0] * x1D * y0D) + (x1y1[0] * x1D * y1D);
|
||||
dst[1] = (x0y0[1] * x0D * y0D) + (x0y1[1] * x0D * y1D) + (x1y0[1] * x1D * y0D) + (x1y1[1] * x1D * y1D);
|
||||
dst[2] = (x0y0[2] * x0D * y0D) + (x0y1[2] * x0D * y1D) + (x1y0[2] * x1D * y0D) + (x1y1[2] * x1D * y1D);
|
||||
dst[3] = (x0y0[3] * x0D * y0D) + (x0y1[3] * x0D * y1D) + (x1y0[3] * x1D * y0D) + (x1y1[3] * x1D * y1D);
|
||||
|
||||
return dst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if texture coordinate touches the silhouette using nearest neighbor.
|
||||
* @param {twgl.v3} vec A texture coordinate.
|
||||
* @return {boolean} If the nearest pixel has an alpha value.
|
||||
*/
|
||||
isTouchingNearest (vec) {
|
||||
if (!this._colorData) return;
|
||||
return getPoint(
|
||||
this,
|
||||
Math.floor(vec[0] * (this._width - 1)),
|
||||
Math.floor(vec[1] * (this._height - 1))
|
||||
) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test to see if any of the 4 pixels used in the linear interpolate touch
|
||||
* the silhouette.
|
||||
* @param {twgl.v3} vec A texture coordinate.
|
||||
* @return {boolean} Any of the pixels have some alpha.
|
||||
*/
|
||||
isTouchingLinear (vec) {
|
||||
if (!this._colorData) return;
|
||||
const x = Math.floor(vec[0] * (this._width - 1));
|
||||
const y = Math.floor(vec[1] * (this._height - 1));
|
||||
return getPoint(this, x, y) > 0 ||
|
||||
getPoint(this, x + 1, y) > 0 ||
|
||||
getPoint(this, x, y + 1) > 0 ||
|
||||
getPoint(this, x + 1, y + 1) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the canvas element reused by Silhouettes to update their data with.
|
||||
* @private
|
||||
* @return {CanvasElement} A canvas to draw bitmap data to.
|
||||
*/
|
||||
static _updateCanvas () {
|
||||
if (typeof __SilhouetteUpdateCanvas === 'undefined') {
|
||||
__SilhouetteUpdateCanvas = document.createElement('canvas');
|
||||
}
|
||||
return __SilhouetteUpdateCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Silhouette;
|
||||
231
scratch-render/src/Skin.js
Normal file
231
scratch-render/src/Skin.js
Normal file
@@ -0,0 +1,231 @@
|
||||
const twgl = require('twgl.js');
|
||||
|
||||
const RenderConstants = require('./RenderConstants');
|
||||
const Silhouette = require('./Silhouette');
|
||||
|
||||
class Skin {
|
||||
/**
|
||||
* Create a Skin, which stores and/or generates textures for use in rendering.
|
||||
* @param {int} id - The unique ID for this Skin.
|
||||
* @param {RenderWebGL} renderer - The renderer which will use this skin.
|
||||
* @constructor
|
||||
*/
|
||||
constructor (id, renderer) {
|
||||
/** @type {RenderWebGL} */
|
||||
this._renderer = renderer;
|
||||
|
||||
/** @type {int} */
|
||||
this._id = id;
|
||||
|
||||
/** @type {Vec3} */
|
||||
this._rotationCenter = twgl.v3.create(0, 0);
|
||||
|
||||
/** @type {WebGLTexture} */
|
||||
this._texture = null;
|
||||
|
||||
/**
|
||||
* The uniforms to be used by the vertex and pixel shaders.
|
||||
* Some of these are used by other parts of the renderer as well.
|
||||
* @type {Object.<string,*>}
|
||||
* @private
|
||||
*/
|
||||
this._uniforms = {
|
||||
/**
|
||||
* The nominal (not necessarily current) size of the current skin.
|
||||
* @type {Array<number>}
|
||||
*/
|
||||
u_skinSize: [0, 0],
|
||||
|
||||
/**
|
||||
* The actual WebGL texture object for the skin.
|
||||
* @type {WebGLTexture}
|
||||
*/
|
||||
u_skin: null
|
||||
};
|
||||
|
||||
/**
|
||||
* A silhouette to store touching data, skins are responsible for keeping it up to date.
|
||||
* @protected
|
||||
*/
|
||||
this._silhouette = new Silhouette();
|
||||
|
||||
/**
|
||||
* Whether this skin might include private information about the user.
|
||||
*/
|
||||
this.private = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of this object. Do not use it after calling this method.
|
||||
*/
|
||||
dispose () {
|
||||
this._id = RenderConstants.ID_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {int} the unique ID for this Skin.
|
||||
*/
|
||||
get id () {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Vec3} the origin, in object space, about which this Skin should rotate.
|
||||
*/
|
||||
get rotationCenter () {
|
||||
return this._rotationCenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @return {Array<number>} the "native" size, in texels, of this skin.
|
||||
*/
|
||||
get size () {
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Should this skin's texture be filtered with nearest-neighbor or linear interpolation at the given scale?
|
||||
* @param {?Array<Number>} scale The screen-space X and Y scaling factors at which this skin's texture will be
|
||||
* displayed, as percentages (100 means 1 "native size" unit is 1 screen pixel; 200 means 2 screen pixels, etc).
|
||||
* @param {Drawable} drawable The drawable that this skin's texture will be applied to.
|
||||
* @return {boolean} True if this skin's texture, as returned by {@link getTexture}, should be filtered with
|
||||
* nearest-neighbor interpolation.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
useNearest (scale, drawable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the center of the current bounding box
|
||||
* @return {Array<number>} the center of the current bounding box
|
||||
*/
|
||||
calculateRotationCenter () {
|
||||
return [this.size[0] / 2, this.size[1] / 2];
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @param {Array<number>} scale - The scaling factors to be used.
|
||||
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given size.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
getTexture (scale) {
|
||||
return this._emptyImageTexture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bounds of the drawable for determining its fenced position.
|
||||
* @param {Array<number>} drawable - The Drawable instance this skin is using.
|
||||
* @param {?Rectangle} result - Optional destination for bounds calculation.
|
||||
* @return {!Rectangle} The drawable's bounds. For compatibility with Scratch 2, we always use getAABB.
|
||||
*/
|
||||
getFenceBounds (drawable, result) {
|
||||
return drawable.getAABB(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update and returns the uniforms for this skin.
|
||||
* @param {Array<number>} scale - The scaling factors to be used.
|
||||
* @returns {object.<string, *>} the shader uniforms to be used when rendering with this Skin.
|
||||
*/
|
||||
getUniforms (scale) {
|
||||
this._uniforms.u_skin = this.getTexture(scale);
|
||||
this._uniforms.u_skinSize = this.size;
|
||||
return this._uniforms;
|
||||
}
|
||||
|
||||
emitWasAltered () {
|
||||
this._renderer.skinWasAltered(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the skin defers silhouette operations until the last possible minute,
|
||||
* this will be called before isTouching uses the silhouette.
|
||||
*/
|
||||
updateSilhouette () {
|
||||
this._silhouette.unlazy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this skin's texture to the given image.
|
||||
* @param {ImageData|HTMLCanvasElement} textureData - The canvas or image data to set the texture to.
|
||||
*/
|
||||
_setTexture (textureData) {
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, this._texture);
|
||||
// Premultiplied alpha is necessary for proper blending.
|
||||
// See http://www.realtimerendering.com/blog/gpus-prefer-premultiplication/
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData);
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||
|
||||
this._silhouette.update(textureData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the contents of this skin to an empty skin.
|
||||
* @fires Skin.event:WasAltered
|
||||
*/
|
||||
setEmptyImageData () {
|
||||
// Free up the current reference to the _texture
|
||||
this._texture = null;
|
||||
|
||||
if (!this._emptyImageData) {
|
||||
// Create a transparent pixel
|
||||
this._emptyImageData = new ImageData(1, 1);
|
||||
|
||||
// Create a new texture and update the silhouette
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
const textureOptions = {
|
||||
auto: true,
|
||||
wrap: gl.CLAMP_TO_EDGE,
|
||||
src: this._emptyImageData
|
||||
};
|
||||
|
||||
// Note: we're using _emptyImageTexture here instead of _texture
|
||||
// so that we can cache this empty texture for later use as needed.
|
||||
// this._texture can get modified by other skins (e.g. BitmapSkin
|
||||
// and SVGSkin, so we can't use that same field for caching)
|
||||
this._emptyImageTexture = twgl.createTexture(gl, textureOptions);
|
||||
}
|
||||
|
||||
this._rotationCenter[0] = 0;
|
||||
this._rotationCenter[1] = 0;
|
||||
|
||||
this._silhouette.update(this._emptyImageData);
|
||||
this.emitWasAltered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this point touch an opaque or translucent point on this skin?
|
||||
* Nearest Neighbor version
|
||||
* The caller is responsible for ensuring this skin's silhouette is up-to-date.
|
||||
* @see updateSilhouette
|
||||
* @see Drawable.updateCPURenderAttributes
|
||||
* @param {twgl.v3} vec A texture coordinate.
|
||||
* @return {boolean} Did it touch?
|
||||
*/
|
||||
isTouchingNearest (vec) {
|
||||
return this._silhouette.isTouchingNearest(vec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this point touch an opaque or translucent point on this skin?
|
||||
* Linear Interpolation version
|
||||
* The caller is responsible for ensuring this skin's silhouette is up-to-date.
|
||||
* @see updateSilhouette
|
||||
* @see Drawable.updateCPURenderAttributes
|
||||
* @param {twgl.v3} vec A texture coordinate.
|
||||
* @return {boolean} Did it touch?
|
||||
*/
|
||||
isTouchingLinear (vec) {
|
||||
return this._silhouette.isTouchingLinear(vec);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Skin;
|
||||
282
scratch-render/src/TextBubbleSkin.js
Normal file
282
scratch-render/src/TextBubbleSkin.js
Normal file
@@ -0,0 +1,282 @@
|
||||
const twgl = require('twgl.js');
|
||||
|
||||
const CanvasMeasurementProvider = require('./util/canvas-measurement-provider');
|
||||
const Skin = require('./Skin');
|
||||
|
||||
const BubbleStyle = {
|
||||
MAX_LINE_WIDTH: 170, // Maximum width, in Scratch pixels, of a single line of text
|
||||
|
||||
MIN_WIDTH: 50, // Minimum width, in Scratch pixels, of a text bubble
|
||||
STROKE_WIDTH: 4, // Thickness of the stroke around the bubble. Only half's visible because it's drawn under the fill
|
||||
PADDING: 10, // Padding around the text area
|
||||
CORNER_RADIUS: 16, // Radius of the rounded corners
|
||||
TAIL_HEIGHT: 12, // Height of the speech bubble's "tail". Probably should be a constant.
|
||||
|
||||
FONT: 'Helvetica', // Font to render the text with
|
||||
FONT_SIZE: 14, // Font size, in Scratch pixels
|
||||
FONT_HEIGHT_RATIO: 0.9, // Height, in Scratch pixels, of the text, as a proportion of the font's size
|
||||
LINE_HEIGHT: 16, // Spacing between each line of text
|
||||
|
||||
COLORS: {
|
||||
BUBBLE_FILL: 'white',
|
||||
BUBBLE_STROKE: 'rgba(0, 0, 0, 0.15)',
|
||||
TEXT_FILL: '#575E75'
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_SCALE = 10;
|
||||
|
||||
class TextBubbleSkin extends Skin {
|
||||
/**
|
||||
* Create a new text bubble skin.
|
||||
* @param {!int} id - The ID for this Skin.
|
||||
* @param {!RenderWebGL} renderer - The renderer which will use this skin.
|
||||
* @constructor
|
||||
* @extends Skin
|
||||
*/
|
||||
constructor (id, renderer) {
|
||||
super(id, renderer);
|
||||
|
||||
/** @type {HTMLCanvasElement} */
|
||||
this._canvas = document.createElement('canvas');
|
||||
|
||||
/** @type {Array<number>} */
|
||||
this._size = [0, 0];
|
||||
|
||||
/** @type {number} */
|
||||
this._renderedScale = 0;
|
||||
|
||||
/** @type {Array<string>} */
|
||||
this._lines = [];
|
||||
|
||||
/** @type {object} */
|
||||
this._textAreaSize = {width: 0, height: 0};
|
||||
|
||||
/** @type {string} */
|
||||
this._bubbleType = '';
|
||||
|
||||
/** @type {boolean} */
|
||||
this._pointsLeft = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
this._textDirty = true;
|
||||
|
||||
/** @type {boolean} */
|
||||
this._textureDirty = true;
|
||||
|
||||
this.measurementProvider = new CanvasMeasurementProvider(this._canvas.getContext('2d'));
|
||||
this.textWrapper = renderer.createTextWrapper(this.measurementProvider);
|
||||
|
||||
this._restyleCanvas();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of this object. Do not use it after calling this method.
|
||||
*/
|
||||
dispose () {
|
||||
if (this._texture) {
|
||||
this._renderer.gl.deleteTexture(this._texture);
|
||||
this._texture = null;
|
||||
}
|
||||
this._canvas = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<number>} the dimensions, in Scratch units, of this skin.
|
||||
*/
|
||||
get size () {
|
||||
if (this._textDirty) {
|
||||
this._reflowLines();
|
||||
}
|
||||
return this._size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set parameters for this text bubble.
|
||||
* @param {!string} type - either "say" or "think".
|
||||
* @param {!string} text - the text for the bubble.
|
||||
* @param {!boolean} pointsLeft - which side the bubble is pointing.
|
||||
*/
|
||||
setTextBubble (type, text, pointsLeft) {
|
||||
this._text = text;
|
||||
this._bubbleType = type;
|
||||
this._pointsLeft = pointsLeft;
|
||||
|
||||
this._textDirty = true;
|
||||
this._textureDirty = true;
|
||||
this.emitWasAltered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-style the canvas after resizing it. This is necessary to ensure proper text measurement.
|
||||
*/
|
||||
_restyleCanvas () {
|
||||
this._canvas.getContext('2d').font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the array of wrapped lines and the text dimensions.
|
||||
*/
|
||||
_reflowLines () {
|
||||
this._lines = this.textWrapper.wrapText(BubbleStyle.MAX_LINE_WIDTH, this._text);
|
||||
|
||||
// Measure width of longest line to avoid extra-wide bubbles
|
||||
let longestLineWidth = 0;
|
||||
for (const line of this._lines) {
|
||||
longestLineWidth = Math.max(longestLineWidth, this.measurementProvider.measureText(line));
|
||||
}
|
||||
|
||||
// Calculate the canvas-space sizes of the padded text area and full text bubble
|
||||
const paddedWidth = Math.max(longestLineWidth, BubbleStyle.MIN_WIDTH) + (BubbleStyle.PADDING * 2);
|
||||
const paddedHeight = (BubbleStyle.LINE_HEIGHT * this._lines.length) + (BubbleStyle.PADDING * 2);
|
||||
|
||||
this._textAreaSize.width = paddedWidth;
|
||||
this._textAreaSize.height = paddedHeight;
|
||||
|
||||
this._size[0] = paddedWidth + BubbleStyle.STROKE_WIDTH;
|
||||
this._size[1] = paddedHeight + BubbleStyle.STROKE_WIDTH + BubbleStyle.TAIL_HEIGHT;
|
||||
|
||||
this._textDirty = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render this text bubble at a certain scale, using the current parameters, to the canvas.
|
||||
* @param {number} scale The scale to render the bubble at
|
||||
*/
|
||||
_renderTextBubble (scale) {
|
||||
const ctx = this._canvas.getContext('2d');
|
||||
|
||||
if (this._textDirty) {
|
||||
this._reflowLines();
|
||||
}
|
||||
|
||||
// Calculate the canvas-space sizes of the padded text area and full text bubble
|
||||
const paddedWidth = this._textAreaSize.width;
|
||||
const paddedHeight = this._textAreaSize.height;
|
||||
|
||||
// Resize the canvas to the correct screen-space size
|
||||
this._canvas.width = Math.ceil(this._size[0] * scale);
|
||||
this._canvas.height = Math.ceil(this._size[1] * scale);
|
||||
this._restyleCanvas();
|
||||
|
||||
// Reset the transform before clearing to ensure 100% clearage
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(BubbleStyle.STROKE_WIDTH * 0.5, BubbleStyle.STROKE_WIDTH * 0.5);
|
||||
|
||||
// If the text bubble points leftward, flip the canvas
|
||||
ctx.save();
|
||||
if (this._pointsLeft) {
|
||||
ctx.scale(-1, 1);
|
||||
ctx.translate(-paddedWidth, 0);
|
||||
}
|
||||
|
||||
// Draw the bubble's rounded borders
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(BubbleStyle.CORNER_RADIUS, paddedHeight);
|
||||
ctx.arcTo(0, paddedHeight, 0, paddedHeight - BubbleStyle.CORNER_RADIUS, BubbleStyle.CORNER_RADIUS);
|
||||
ctx.arcTo(0, 0, paddedWidth, 0, BubbleStyle.CORNER_RADIUS);
|
||||
ctx.arcTo(paddedWidth, 0, paddedWidth, paddedHeight, BubbleStyle.CORNER_RADIUS);
|
||||
ctx.arcTo(paddedWidth, paddedHeight, paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight,
|
||||
BubbleStyle.CORNER_RADIUS);
|
||||
|
||||
// Translate the canvas so we don't have to do a bunch of width/height arithmetic
|
||||
ctx.save();
|
||||
ctx.translate(paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight);
|
||||
|
||||
// Draw the bubble's "tail"
|
||||
if (this._bubbleType === 'say') {
|
||||
// For a speech bubble, draw one swoopy thing
|
||||
ctx.bezierCurveTo(0, 4, 4, 8, 4, 10);
|
||||
ctx.arcTo(4, 12, 2, 12, 2);
|
||||
ctx.bezierCurveTo(-1, 12, -11, 8, -16, 0);
|
||||
|
||||
ctx.closePath();
|
||||
} else {
|
||||
// For a thinking bubble, draw a partial circle attached to the bubble...
|
||||
ctx.arc(-16, 0, 4, 0, Math.PI);
|
||||
|
||||
ctx.closePath();
|
||||
|
||||
// and two circles detached from it
|
||||
ctx.moveTo(-7, 7.25);
|
||||
ctx.arc(-9.25, 7.25, 2.25, 0, Math.PI * 2);
|
||||
|
||||
ctx.moveTo(0, 9.5);
|
||||
ctx.arc(-1.5, 9.5, 1.5, 0, Math.PI * 2);
|
||||
}
|
||||
|
||||
// Un-translate the canvas and fill + stroke the text bubble
|
||||
ctx.restore();
|
||||
|
||||
ctx.fillStyle = BubbleStyle.COLORS.BUBBLE_FILL;
|
||||
ctx.strokeStyle = BubbleStyle.COLORS.BUBBLE_STROKE;
|
||||
ctx.lineWidth = BubbleStyle.STROKE_WIDTH;
|
||||
|
||||
ctx.stroke();
|
||||
ctx.fill();
|
||||
|
||||
// Un-flip the canvas if it was flipped
|
||||
ctx.restore();
|
||||
|
||||
// Draw each line of text
|
||||
ctx.fillStyle = BubbleStyle.COLORS.TEXT_FILL;
|
||||
ctx.font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
|
||||
const lines = this._lines;
|
||||
for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
|
||||
const line = lines[lineNumber];
|
||||
ctx.fillText(
|
||||
line,
|
||||
BubbleStyle.PADDING,
|
||||
BubbleStyle.PADDING + (BubbleStyle.LINE_HEIGHT * lineNumber) +
|
||||
(BubbleStyle.FONT_HEIGHT_RATIO * BubbleStyle.FONT_SIZE)
|
||||
);
|
||||
}
|
||||
|
||||
this._renderedScale = scale;
|
||||
}
|
||||
|
||||
updateSilhouette (scale = [100, 100]) {
|
||||
// Ensure a silhouette exists.
|
||||
this.getTexture(scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<number>} scale - The scaling factors to be used, each in the [0,100] range.
|
||||
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale.
|
||||
*/
|
||||
getTexture (scale) {
|
||||
// The texture only ever gets uniform scale. Take the larger of the two axes.
|
||||
const scaleMax = scale ? Math.max(Math.abs(scale[0]), Math.abs(scale[1])) : 100;
|
||||
const requestedScale = Math.min(MAX_SCALE, scaleMax / 100);
|
||||
|
||||
// If we already rendered the text bubble at this scale, we can skip re-rendering it.
|
||||
if (this._textureDirty || this._renderedScale !== requestedScale) {
|
||||
this._renderTextBubble(requestedScale);
|
||||
this._textureDirty = false;
|
||||
|
||||
const context = this._canvas.getContext('2d');
|
||||
const textureData = context.getImageData(0, 0, this._canvas.width, this._canvas.height);
|
||||
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
if (this._texture === null) {
|
||||
const textureOptions = {
|
||||
auto: false,
|
||||
wrap: gl.CLAMP_TO_EDGE
|
||||
};
|
||||
|
||||
this._texture = twgl.createTexture(gl, textureOptions);
|
||||
}
|
||||
|
||||
this._setTexture(textureData);
|
||||
}
|
||||
|
||||
return this._texture;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TextBubbleSkin;
|
||||
7
scratch-render/src/index.js
Normal file
7
scratch-render/src/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const RenderWebGL = require('./RenderWebGL');
|
||||
|
||||
/**
|
||||
* Export for NPM & Node.js
|
||||
* @type {RenderWebGL}
|
||||
*/
|
||||
module.exports = RenderWebGL;
|
||||
9
scratch-render/src/playground/.eslintrc.js
Normal file
9
scratch-render/src/playground/.eslintrc.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
extends: ['scratch'],
|
||||
env: {
|
||||
browser: true
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off'
|
||||
}
|
||||
};
|
||||
37
scratch-render/src/playground/getMousePosition.js
Normal file
37
scratch-render/src/playground/getMousePosition.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Adapted from code by Simon Sarris: http://stackoverflow.com/a/10450761
|
||||
const getMousePos = function (event, element) {
|
||||
const stylePaddingLeft = parseInt(document.defaultView.getComputedStyle(element, null).paddingLeft, 10) || 0;
|
||||
const stylePaddingTop = parseInt(document.defaultView.getComputedStyle(element, null).paddingTop, 10) || 0;
|
||||
const styleBorderLeft = parseInt(document.defaultView.getComputedStyle(element, null).borderLeftWidth, 10) || 0;
|
||||
const styleBorderTop = parseInt(document.defaultView.getComputedStyle(element, null).borderTopWidth, 10) || 0;
|
||||
|
||||
// Some pages have fixed-position bars at the top or left of the page
|
||||
// They will mess up mouse coordinates and this fixes that
|
||||
const html = document.body.parentNode;
|
||||
const htmlTop = html.offsetTop;
|
||||
const htmlLeft = html.offsetLeft;
|
||||
|
||||
// Compute the total offset. It's possible to cache this if you want
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
if (typeof element.offsetParent !== 'undefined') {
|
||||
do {
|
||||
offsetX += element.offsetLeft;
|
||||
offsetY += element.offsetTop;
|
||||
} while ((element = element.offsetParent));
|
||||
}
|
||||
|
||||
// Add padding and border style widths to offset
|
||||
// Also add the <html> offsets in case there's a position:fixed bar
|
||||
// This part is not strictly necessary, it depends on your styling
|
||||
offsetX += stylePaddingLeft + styleBorderLeft + htmlLeft;
|
||||
offsetY += stylePaddingTop + styleBorderTop + htmlTop;
|
||||
|
||||
// We return a simple javascript object with x and y defined
|
||||
return {
|
||||
x: event.pageX - offsetX,
|
||||
y: event.pageY - offsetY
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = getMousePos;
|
||||
41
scratch-render/src/playground/index.html
Normal file
41
scratch-render/src/playground/index.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Scratch WebGL rendering demo</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="scratch-stage" width="10" height="10"></canvas>
|
||||
<canvas id="debug-canvas" width="10" height="10"></canvas>
|
||||
<p>
|
||||
<label for="fudgeproperty">Property to tweak:</label>
|
||||
<select id="fudgeproperty">
|
||||
<option value="posx">Position X</option>
|
||||
<option value="posy">Position Y</option>
|
||||
<option value="direction">Direction</option>
|
||||
<option value="scalex">Scale X</option>
|
||||
<option value="scaley">Scale Y</option>
|
||||
<option value="scaleboth">Scale (both dimensions)</option>
|
||||
<option value="color">Color</option>
|
||||
<option value="fisheye">Fisheye</option>
|
||||
<option value="whirl">Whirl</option>
|
||||
<option value="pixelate">Pixelate</option>
|
||||
<option value="mosaic">Mosaic</option>
|
||||
<option value="brightness">Brightness</option>
|
||||
<option value="ghost">Ghost</option>
|
||||
</select>
|
||||
<label for="fudge">Property Value:</label>
|
||||
<input type="range" id="fudge" style="width:50%" value="90" min="-90" max="270" step="any">
|
||||
</p>
|
||||
<p>
|
||||
<label for="stage-scale">Stage scale:</label>
|
||||
<input type="range" style="width:50%" id="stage-scale" value="1" min="1" max="2.5" step="any">
|
||||
</p>
|
||||
<p>
|
||||
<label for="fudgeMin">Min:</label><input id="fudgeMin" type="number" value="0">
|
||||
<label for="fudgeMax">Max:</label><input id="fudgeMax" type="number" value="200">
|
||||
</p>
|
||||
<script src="playground.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
202
scratch-render/src/playground/playground.js
Normal file
202
scratch-render/src/playground/playground.js
Normal file
@@ -0,0 +1,202 @@
|
||||
const ScratchRender = require('../RenderWebGL');
|
||||
const getMousePosition = require('./getMousePosition');
|
||||
|
||||
const canvas = document.getElementById('scratch-stage');
|
||||
let fudge = 90;
|
||||
const renderer = new ScratchRender(canvas);
|
||||
renderer.setLayerGroupOrdering(['group1']);
|
||||
|
||||
const drawableID = renderer.createDrawable('group1');
|
||||
renderer.updateDrawableProperties(drawableID, {
|
||||
position: [0, 0],
|
||||
scale: [100, 100],
|
||||
direction: 90
|
||||
});
|
||||
|
||||
const WantedSkinType = {
|
||||
bitmap: 'bitmap',
|
||||
vector: 'vector',
|
||||
pen: 'pen'
|
||||
};
|
||||
|
||||
const drawableID2 = renderer.createDrawable('group1');
|
||||
const wantedSkin = WantedSkinType.vector;
|
||||
|
||||
// Bitmap (squirrel)
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => {
|
||||
const bitmapSkinId = renderer.createBitmapSkin(image);
|
||||
if (wantedSkin === WantedSkinType.bitmap) {
|
||||
renderer.updateDrawableProperties(drawableID2, {
|
||||
skinId: bitmapSkinId
|
||||
});
|
||||
}
|
||||
});
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.src = 'https://cdn.assets.scratch.mit.edu/internalapi/asset/7e24c99c1b853e52f8e7f9004416fa34.png/get/';
|
||||
|
||||
// SVG (cat 1-a)
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener('load', () => {
|
||||
const skinId = renderer.createSVGSkin(xhr.responseText);
|
||||
if (wantedSkin === WantedSkinType.vector) {
|
||||
renderer.updateDrawableProperties(drawableID2, {
|
||||
skinId: skinId
|
||||
});
|
||||
}
|
||||
});
|
||||
xhr.open('GET', 'https://cdn.assets.scratch.mit.edu/internalapi/asset/b7853f557e4426412e64bb3da6531a99.svg/get/');
|
||||
xhr.send();
|
||||
|
||||
if (wantedSkin === WantedSkinType.pen) {
|
||||
const penSkinID = renderer.createPenSkin();
|
||||
|
||||
renderer.updateDrawableProperties(drawableID2, {
|
||||
skinId: penSkinID
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', event => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
renderer.penLine(penSkinID, {
|
||||
color4f: [Math.random(), Math.random(), Math.random(), 1],
|
||||
diameter: 8
|
||||
},
|
||||
x - 240, 180 - y, (Math.random() * 480) - 240, (Math.random() * 360) - 180);
|
||||
});
|
||||
}
|
||||
|
||||
let posX = 0;
|
||||
let posY = 0;
|
||||
let scaleX = 100;
|
||||
let scaleY = 100;
|
||||
let fudgeProperty = 'posx';
|
||||
|
||||
const fudgeInput = document.getElementById('fudge');
|
||||
const fudgePropertyInput = document.getElementById('fudgeproperty');
|
||||
const fudgeMinInput = document.getElementById('fudgeMin');
|
||||
const fudgeMaxInput = document.getElementById('fudgeMax');
|
||||
|
||||
/* eslint require-jsdoc: 0 */
|
||||
const updateFudgeProperty = event => {
|
||||
fudgeProperty = event.target.value;
|
||||
};
|
||||
|
||||
const updateFudgeMin = event => {
|
||||
fudgeInput.min = event.target.valueAsNumber;
|
||||
};
|
||||
|
||||
const updateFudgeMax = event => {
|
||||
fudgeInput.max = event.target.valueAsNumber;
|
||||
};
|
||||
|
||||
fudgePropertyInput.addEventListener('change', updateFudgeProperty);
|
||||
fudgePropertyInput.addEventListener('init', updateFudgeProperty);
|
||||
|
||||
fudgeMinInput.addEventListener('change', updateFudgeMin);
|
||||
fudgeMinInput.addEventListener('init', updateFudgeMin);
|
||||
|
||||
fudgeMaxInput.addEventListener('change', updateFudgeMax);
|
||||
fudgeMaxInput.addEventListener('init', updateFudgeMax);
|
||||
|
||||
// Ugly hack to properly set the values of the inputs on page load,
|
||||
// since they persist across reloads, at least in Firefox.
|
||||
// The best ugly hacks are the ones that reduce code duplication!
|
||||
fudgePropertyInput.dispatchEvent(new CustomEvent('init'));
|
||||
fudgeMinInput.dispatchEvent(new CustomEvent('init'));
|
||||
fudgeMaxInput.dispatchEvent(new CustomEvent('init'));
|
||||
fudgeInput.dispatchEvent(new CustomEvent('init'));
|
||||
|
||||
const handleFudgeChanged = function (event) {
|
||||
fudge = event.target.valueAsNumber;
|
||||
const props = {};
|
||||
switch (fudgeProperty) {
|
||||
case 'posx':
|
||||
props.position = [fudge, posY];
|
||||
posX = fudge;
|
||||
break;
|
||||
case 'posy':
|
||||
props.position = [posX, fudge];
|
||||
posY = fudge;
|
||||
break;
|
||||
case 'direction':
|
||||
props.direction = fudge;
|
||||
break;
|
||||
case 'scalex':
|
||||
props.scale = [fudge, scaleY];
|
||||
scaleX = fudge;
|
||||
break;
|
||||
case 'scaley':
|
||||
props.scale = [scaleX, fudge];
|
||||
scaleY = fudge;
|
||||
break;
|
||||
case 'scaleboth':
|
||||
props.scale = [fudge, fudge];
|
||||
scaleX = fudge;
|
||||
scaleY = fudge;
|
||||
break;
|
||||
case 'color':
|
||||
props.color = fudge;
|
||||
break;
|
||||
case 'whirl':
|
||||
props.whirl = fudge;
|
||||
break;
|
||||
case 'fisheye':
|
||||
props.fisheye = fudge;
|
||||
break;
|
||||
case 'pixelate':
|
||||
props.pixelate = fudge;
|
||||
break;
|
||||
case 'mosaic':
|
||||
props.mosaic = fudge;
|
||||
break;
|
||||
case 'brightness':
|
||||
props.brightness = fudge;
|
||||
break;
|
||||
case 'ghost':
|
||||
props.ghost = fudge;
|
||||
break;
|
||||
}
|
||||
renderer.updateDrawableProperties(drawableID2, props);
|
||||
};
|
||||
|
||||
fudgeInput.addEventListener('input', handleFudgeChanged);
|
||||
fudgeInput.addEventListener('change', handleFudgeChanged);
|
||||
fudgeInput.addEventListener('init', handleFudgeChanged);
|
||||
|
||||
const updateStageScale = event => {
|
||||
renderer.resize(480 * event.target.valueAsNumber, 360 * event.target.valueAsNumber);
|
||||
};
|
||||
|
||||
const stageScaleInput = document.getElementById('stage-scale');
|
||||
|
||||
stageScaleInput.addEventListener('input', updateStageScale);
|
||||
stageScaleInput.addEventListener('change', updateStageScale);
|
||||
|
||||
canvas.addEventListener('mousemove', event => {
|
||||
const mousePos = getMousePosition(event, canvas);
|
||||
renderer.extractColor(mousePos.x, mousePos.y, 30);
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', event => {
|
||||
const mousePos = getMousePosition(event, canvas);
|
||||
const pickID = renderer.pick(mousePos.x, mousePos.y);
|
||||
console.log(`You clicked on ${(pickID < 0 ? 'nothing' : `ID# ${pickID}`)}`);
|
||||
if (pickID >= 0) {
|
||||
console.dir(renderer.extractDrawableScreenSpace(pickID, mousePos.x, mousePos.y));
|
||||
}
|
||||
});
|
||||
|
||||
const drawStep = function () {
|
||||
renderer.draw();
|
||||
// renderer.getBounds(drawableID2);
|
||||
// renderer.isTouchingColor(drawableID2, [255,255,255]);
|
||||
requestAnimationFrame(drawStep);
|
||||
};
|
||||
drawStep();
|
||||
|
||||
const debugCanvas = /** @type {canvas} */ document.getElementById('debug-canvas');
|
||||
renderer.setDebugCanvas(debugCanvas);
|
||||
73
scratch-render/src/playground/queryPlayground.html
Normal file
73
scratch-render/src/playground/queryPlayground.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Scratch WebGL Query Playground</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
<style>
|
||||
input[type=range][orient=vertical] {
|
||||
writing-mode: bt-lr; /* IE */
|
||||
-webkit-appearance: slider-vertical;
|
||||
width: 1rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
canvas {
|
||||
/* https://stackoverflow.com/a/7665647 */
|
||||
image-rendering: optimizeSpeed; /* Older versions of FF */
|
||||
image-rendering: -moz-crisp-edges; /* FF 6.0+ */
|
||||
image-rendering: -webkit-optimize-contrast; /* Safari */
|
||||
image-rendering: -o-crisp-edges; /* OS X & Windows Opera (12.02+) */
|
||||
image-rendering: pixelated; /* Awesome future-browsers */
|
||||
-ms-interpolation-mode: nearest-neighbor; /* IE */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend>Query Canvases</legend>
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<fieldset>
|
||||
<legend>GPU</legend>
|
||||
<div>Touching color A? <span id="gpuTouchingA">maybe</span></div>
|
||||
<div>Touching color B? <span id="gpuTouchingB">maybe</span></div>
|
||||
<canvas id="gpuQueryCanvas" width="480" height="360" style="height: 20rem"></canvas>
|
||||
</fieldset>
|
||||
</td>
|
||||
<td>
|
||||
<fieldset>
|
||||
<legend>CPU</legend>
|
||||
<div>Touching color A? <span id="cpuTouchingA">maybe</span></div>
|
||||
<div>Touching color B? <span id="cpuTouchingB">maybe</span></div>
|
||||
<canvas id="cpuQueryCanvas" width="480" height="360" style="height: 20rem"></canvas>
|
||||
</fieldset>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Render Canvas</legend>
|
||||
<div>Cursor Position: <span id="cursorPosition">somewhere</span></div>
|
||||
<table>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>
|
||||
<input id="cursorX" type="range" step="0.25" value="0" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input id="cursorY" type="range" orient="vertical" step="0.25" value="0" />
|
||||
</td>
|
||||
<td>
|
||||
<canvas id="renderCanvas" width="480" height="360"></canvas>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
</body>
|
||||
<script src="queryPlayground.js"></script>
|
||||
</html>
|
||||
196
scratch-render/src/playground/queryPlayground.js
Normal file
196
scratch-render/src/playground/queryPlayground.js
Normal file
@@ -0,0 +1,196 @@
|
||||
const ScratchRender = require('../RenderWebGL');
|
||||
const getMousePosition = require('./getMousePosition');
|
||||
|
||||
const renderCanvas = document.getElementById('renderCanvas');
|
||||
const gpuQueryCanvas = document.getElementById('gpuQueryCanvas');
|
||||
const cpuQueryCanvas = document.getElementById('cpuQueryCanvas');
|
||||
const inputCursorX = document.getElementById('cursorX');
|
||||
const inputCursorY = document.getElementById('cursorY');
|
||||
const labelCursorPosition = document.getElementById('cursorPosition');
|
||||
const labelGpuTouchingA = document.getElementById('gpuTouchingA');
|
||||
const labelGpuTouchingB = document.getElementById('gpuTouchingB');
|
||||
const labelCpuTouchingA = document.getElementById('cpuTouchingA');
|
||||
const labelCpuTouchingB = document.getElementById('cpuTouchingB');
|
||||
|
||||
const drawables = {
|
||||
testPattern: -1,
|
||||
cursor: -1
|
||||
};
|
||||
|
||||
const colors = {
|
||||
cursor: [255, 0, 0],
|
||||
patternA: [0, 255, 0],
|
||||
patternB: [0, 0, 255]
|
||||
};
|
||||
|
||||
const renderer = new ScratchRender(renderCanvas);
|
||||
|
||||
const handleResizeRenderCanvas = () => {
|
||||
const halfWidth = renderCanvas.clientWidth / 2;
|
||||
const halfHeight = renderCanvas.clientHeight / 2;
|
||||
|
||||
inputCursorX.style.width = `${renderCanvas.clientWidth}px`;
|
||||
inputCursorY.style.height = `${renderCanvas.clientHeight}px`;
|
||||
inputCursorX.min = -halfWidth;
|
||||
inputCursorX.max = halfWidth;
|
||||
inputCursorY.min = -halfHeight;
|
||||
inputCursorY.max = halfHeight;
|
||||
};
|
||||
renderCanvas.addEventListener('resize', handleResizeRenderCanvas);
|
||||
handleResizeRenderCanvas();
|
||||
|
||||
const handleCursorPositionChanged = () => {
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const cursorX = inputCursorX.valueAsNumber / devicePixelRatio;
|
||||
const cursorY = inputCursorY.valueAsNumber / devicePixelRatio;
|
||||
const positionHTML = `${cursorX}, ${cursorY}`;
|
||||
labelCursorPosition.innerHTML = positionHTML;
|
||||
if (drawables.cursor >= 0) {
|
||||
renderer.draw();
|
||||
renderer.updateDrawableProperties(drawables.cursor, {
|
||||
position: [cursorX, cursorY]
|
||||
});
|
||||
|
||||
renderer.setUseGpuMode(ScratchRender.UseGpuModes.ForceGPU);
|
||||
renderer.setDebugCanvas(gpuQueryCanvas);
|
||||
const isGpuTouchingA = renderer.isTouchingColor(drawables.cursor, colors.patternA);
|
||||
const isGpuTouchingB = renderer.isTouchingColor(drawables.cursor, colors.patternB);
|
||||
labelGpuTouchingA.innerHTML = isGpuTouchingA ? 'yes' : 'no';
|
||||
labelGpuTouchingB.innerHTML = isGpuTouchingB ? 'yes' : 'no';
|
||||
|
||||
renderer.setUseGpuMode(ScratchRender.UseGpuModes.ForceCPU);
|
||||
renderer.setDebugCanvas(cpuQueryCanvas);
|
||||
const isCpuTouchingA = renderer.isTouchingColor(drawables.cursor, colors.patternA);
|
||||
const isCpuTouchingB = renderer.isTouchingColor(drawables.cursor, colors.patternB);
|
||||
labelCpuTouchingA.innerHTML = isCpuTouchingA ? 'yes' : 'no';
|
||||
labelCpuTouchingB.innerHTML = isCpuTouchingB ? 'yes' : 'no';
|
||||
|
||||
renderer.setUseGpuMode(ScratchRender.UseGpuModes.Automatic);
|
||||
}
|
||||
};
|
||||
inputCursorX.addEventListener('change', handleCursorPositionChanged);
|
||||
inputCursorY.addEventListener('change', handleCursorPositionChanged);
|
||||
inputCursorX.addEventListener('input', handleCursorPositionChanged);
|
||||
inputCursorY.addEventListener('input', handleCursorPositionChanged);
|
||||
handleCursorPositionChanged();
|
||||
|
||||
let trackingMouse = true;
|
||||
const handleMouseMove = event => {
|
||||
if (trackingMouse) {
|
||||
const mousePosition = getMousePosition(event, renderCanvas);
|
||||
inputCursorX.value = mousePosition.x - (renderCanvas.clientWidth / 2);
|
||||
inputCursorY.value = (renderCanvas.clientHeight / 2) - mousePosition.y;
|
||||
handleCursorPositionChanged();
|
||||
}
|
||||
};
|
||||
renderCanvas.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
renderCanvas.addEventListener('click', event => {
|
||||
trackingMouse = !trackingMouse;
|
||||
if (trackingMouse) {
|
||||
handleMouseMove(event);
|
||||
}
|
||||
});
|
||||
|
||||
const rgb2fillStyle = rgb => (
|
||||
`rgb(${rgb[0]},${rgb[1]},${rgb[2]})`
|
||||
);
|
||||
|
||||
const makeCursorImage = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
context.fillStyle = rgb2fillStyle(colors.cursor);
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const makeTestPatternImage = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 480;
|
||||
canvas.height = 360;
|
||||
|
||||
const patternA = rgb2fillStyle(colors.patternA);
|
||||
const patternB = rgb2fillStyle(colors.patternB);
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
context.fillStyle = patternA;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
context.fillStyle = patternB;
|
||||
const xSplit1 = Math.floor(canvas.width * 0.25);
|
||||
const xSplit2 = Math.floor(canvas.width * 0.5);
|
||||
const xSplit3 = Math.floor(canvas.width * 0.75);
|
||||
const ySplit = Math.floor(canvas.height * 0.5);
|
||||
for (let y = 0; y < ySplit; y += 2) {
|
||||
context.fillRect(0, y, xSplit2, 1);
|
||||
}
|
||||
for (let x = xSplit2; x < canvas.width; x += 2) {
|
||||
context.fillRect(x, 0, 1, ySplit);
|
||||
}
|
||||
for (let x = 0; x < xSplit1; x += 2) {
|
||||
for (let y = ySplit; y < canvas.height; y += 2) {
|
||||
context.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
for (let x = xSplit1; x < xSplit2; x += 3) {
|
||||
for (let y = ySplit; y < canvas.height; y += 3) {
|
||||
context.fillRect(x, y, 2, 2);
|
||||
}
|
||||
}
|
||||
for (let x = xSplit2; x < xSplit3; ++x) {
|
||||
for (let y = ySplit; y < canvas.height; ++y) {
|
||||
context.fillStyle = (x + y) % 2 ? patternB : patternA;
|
||||
context.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
for (let x = xSplit3; x < canvas.width; x += 2) {
|
||||
for (let y = ySplit; y < canvas.height; y += 2) {
|
||||
context.fillStyle = (x + y) % 4 ? patternB : patternA;
|
||||
context.fillRect(x, y, 2, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const makeTestPatternDrawable = function (group) {
|
||||
const image = makeTestPatternImage();
|
||||
const skinId = renderer.createBitmapSkin(image, 1);
|
||||
const drawableId = renderer.createDrawable(group);
|
||||
renderer.updateDrawableProperties(drawableId, {skinId});
|
||||
return drawableId;
|
||||
};
|
||||
|
||||
const makeCursorDrawable = function (group) {
|
||||
const image = makeCursorImage();
|
||||
const skinId = renderer.createBitmapSkin(image, 1, [0, 0]);
|
||||
const drawableId = renderer.createDrawable(group);
|
||||
renderer.updateDrawableProperties(drawableId, {skinId});
|
||||
return drawableId;
|
||||
};
|
||||
|
||||
const initRendering = () => {
|
||||
const layerGroup = {
|
||||
testPattern: 'testPattern',
|
||||
cursor: 'cursor'
|
||||
};
|
||||
renderer.setLayerGroupOrdering([layerGroup.testPattern, layerGroup.cursor]);
|
||||
drawables.testPattern = makeTestPatternDrawable(layerGroup.testPattern);
|
||||
drawables.cursor = makeCursorDrawable(layerGroup.cursor);
|
||||
|
||||
const corner00 = makeCursorDrawable(layerGroup.cursor);
|
||||
const corner01 = makeCursorDrawable(layerGroup.cursor);
|
||||
const corner10 = makeCursorDrawable(layerGroup.cursor);
|
||||
const corner11 = makeCursorDrawable(layerGroup.cursor);
|
||||
|
||||
renderer.updateDrawableProperties(corner00, {position: [-240, -179]});
|
||||
renderer.updateDrawableProperties(corner01, {position: [-240, 180]});
|
||||
renderer.updateDrawableProperties(corner10, {position: [239, -179]});
|
||||
renderer.updateDrawableProperties(corner11, {position: [239, 180]});
|
||||
};
|
||||
|
||||
initRendering();
|
||||
renderer.draw();
|
||||
11
scratch-render/src/playground/style.css
Normal file
11
scratch-render/src/playground/style.css
Normal file
@@ -0,0 +1,11 @@
|
||||
body {
|
||||
background: lightsteelblue;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 3px dashed black;
|
||||
}
|
||||
|
||||
#debug-canvas {
|
||||
border-color: red;
|
||||
}
|
||||
12
scratch-render/src/renderer.css
Normal file
12
scratch-render/src/renderer.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.scratch-render-overlays {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scratch-render-overlays > * {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
249
scratch-render/src/shaders/sprite.frag
Normal file
249
scratch-render/src/shaders/sprite.frag
Normal file
@@ -0,0 +1,249 @@
|
||||
precision mediump float;
|
||||
|
||||
#ifdef DRAW_MODE_silhouette
|
||||
uniform vec4 u_silhouetteColor;
|
||||
#else // DRAW_MODE_silhouette
|
||||
# ifdef ENABLE_color
|
||||
uniform float u_color;
|
||||
# endif // ENABLE_color
|
||||
# ifdef ENABLE_brightness
|
||||
uniform float u_brightness;
|
||||
# endif // ENABLE_brightness
|
||||
#endif // DRAW_MODE_silhouette
|
||||
|
||||
#ifdef DRAW_MODE_colorMask
|
||||
uniform vec3 u_colorMask;
|
||||
uniform float u_colorMaskTolerance;
|
||||
#endif // DRAW_MODE_colorMask
|
||||
|
||||
#ifdef ENABLE_fisheye
|
||||
uniform float u_fisheye;
|
||||
#endif // ENABLE_fisheye
|
||||
#ifdef ENABLE_whirl
|
||||
uniform float u_whirl;
|
||||
#endif // ENABLE_whirl
|
||||
#ifdef ENABLE_pixelate
|
||||
uniform float u_pixelate;
|
||||
uniform vec2 u_skinSize;
|
||||
#endif // ENABLE_pixelate
|
||||
#ifdef ENABLE_mosaic
|
||||
uniform float u_mosaic;
|
||||
#endif // ENABLE_mosaic
|
||||
#ifdef ENABLE_ghost
|
||||
uniform float u_ghost;
|
||||
#endif // ENABLE_ghost
|
||||
|
||||
#ifdef DRAW_MODE_line
|
||||
varying vec4 v_lineColor;
|
||||
varying float v_lineThickness;
|
||||
varying float v_lineLength;
|
||||
#endif // DRAW_MODE_line
|
||||
|
||||
#ifdef DRAW_MODE_background
|
||||
uniform vec4 u_backgroundColor;
|
||||
#endif // DRAW_MODE_background
|
||||
|
||||
uniform sampler2D u_skin;
|
||||
|
||||
#ifndef DRAW_MODE_background
|
||||
varying vec2 v_texCoord;
|
||||
#endif
|
||||
|
||||
// Add this to divisors to prevent division by 0, which results in NaNs propagating through calculations.
|
||||
// Smaller values can cause problems on some mobile devices.
|
||||
const float epsilon = 1e-3;
|
||||
|
||||
#if !defined(DRAW_MODE_silhouette) && (defined(ENABLE_color))
|
||||
// Branchless color conversions based on code from:
|
||||
// http://www.chilliant.com/rgb2hsv.html by Ian Taylor
|
||||
// Based in part on work by Sam Hocevar and Emil Persson
|
||||
// See also: https://en.wikipedia.org/wiki/HSL_and_HSV#Formal_derivation
|
||||
|
||||
|
||||
// Convert an RGB color to Hue, Saturation, and Value.
|
||||
// All components of input and output are expected to be in the [0,1] range.
|
||||
vec3 convertRGB2HSV(vec3 rgb)
|
||||
{
|
||||
// Hue calculation has 3 cases, depending on which RGB component is largest, and one of those cases involves a "mod"
|
||||
// operation. In order to avoid that "mod" we split the M==R case in two: one for G<B and one for B>G. The B>G case
|
||||
// will be calculated in the negative and fed through abs() in the hue calculation at the end.
|
||||
// See also: https://en.wikipedia.org/wiki/HSL_and_HSV#Hue_and_chroma
|
||||
const vec4 hueOffsets = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
|
||||
|
||||
// temp1.xy = sort B & G (largest first)
|
||||
// temp1.z = the hue offset we'll use if it turns out that R is the largest component (M==R)
|
||||
// temp1.w = the hue offset we'll use if it turns out that R is not the largest component (M==G or M==B)
|
||||
vec4 temp1 = rgb.b > rgb.g ? vec4(rgb.bg, hueOffsets.wz) : vec4(rgb.gb, hueOffsets.xy);
|
||||
|
||||
// temp2.x = the largest component of RGB ("M" / "Max")
|
||||
// temp2.yw = the smaller components of RGB, ordered for the hue calculation (not necessarily sorted by magnitude!)
|
||||
// temp2.z = the hue offset we'll use in the hue calculation
|
||||
vec4 temp2 = rgb.r > temp1.x ? vec4(rgb.r, temp1.yzx) : vec4(temp1.xyw, rgb.r);
|
||||
|
||||
// m = the smallest component of RGB ("min")
|
||||
float m = min(temp2.y, temp2.w);
|
||||
|
||||
// Chroma = M - m
|
||||
float C = temp2.x - m;
|
||||
|
||||
// Value = M
|
||||
float V = temp2.x;
|
||||
|
||||
return vec3(
|
||||
abs(temp2.z + (temp2.w - temp2.y) / (6.0 * C + epsilon)), // Hue
|
||||
C / (temp2.x + epsilon), // Saturation
|
||||
V); // Value
|
||||
}
|
||||
|
||||
vec3 convertHue2RGB(float hue)
|
||||
{
|
||||
float r = abs(hue * 6.0 - 3.0) - 1.0;
|
||||
float g = 2.0 - abs(hue * 6.0 - 2.0);
|
||||
float b = 2.0 - abs(hue * 6.0 - 4.0);
|
||||
return clamp(vec3(r, g, b), 0.0, 1.0);
|
||||
}
|
||||
|
||||
vec3 convertHSV2RGB(vec3 hsv)
|
||||
{
|
||||
vec3 rgb = convertHue2RGB(hsv.x);
|
||||
float c = hsv.z * hsv.y;
|
||||
return rgb * c + hsv.z - c;
|
||||
}
|
||||
#endif // !defined(DRAW_MODE_silhouette) && (defined(ENABLE_color))
|
||||
|
||||
const vec2 kCenter = vec2(0.5, 0.5);
|
||||
|
||||
void main()
|
||||
{
|
||||
#if !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background))
|
||||
vec2 texcoord0 = v_texCoord;
|
||||
|
||||
#ifdef ENABLE_mosaic
|
||||
texcoord0 = fract(u_mosaic * texcoord0);
|
||||
#endif // ENABLE_mosaic
|
||||
|
||||
#ifdef ENABLE_pixelate
|
||||
{
|
||||
// TODO: clean up "pixel" edges
|
||||
vec2 pixelTexelSize = u_skinSize / u_pixelate;
|
||||
texcoord0 = (floor(texcoord0 * pixelTexelSize) + kCenter) / pixelTexelSize;
|
||||
}
|
||||
#endif // ENABLE_pixelate
|
||||
|
||||
#ifdef ENABLE_whirl
|
||||
{
|
||||
const float kRadius = 0.5;
|
||||
vec2 offset = texcoord0 - kCenter;
|
||||
float offsetMagnitude = length(offset);
|
||||
float whirlFactor = max(1.0 - (offsetMagnitude / kRadius), 0.0);
|
||||
float whirlActual = u_whirl * whirlFactor * whirlFactor;
|
||||
float sinWhirl = sin(whirlActual);
|
||||
float cosWhirl = cos(whirlActual);
|
||||
mat2 rotationMatrix = mat2(
|
||||
cosWhirl, -sinWhirl,
|
||||
sinWhirl, cosWhirl
|
||||
);
|
||||
|
||||
texcoord0 = rotationMatrix * offset + kCenter;
|
||||
}
|
||||
#endif // ENABLE_whirl
|
||||
|
||||
#ifdef ENABLE_fisheye
|
||||
{
|
||||
vec2 vec = (texcoord0 - kCenter) / kCenter;
|
||||
float vecLength = length(vec);
|
||||
float r = pow(min(vecLength, 1.0), u_fisheye) * max(1.0, vecLength);
|
||||
vec2 unit = vec / vecLength;
|
||||
|
||||
texcoord0 = kCenter + r * unit * kCenter;
|
||||
}
|
||||
#endif // ENABLE_fisheye
|
||||
|
||||
gl_FragColor = texture2D(u_skin, texcoord0);
|
||||
|
||||
#if defined(ENABLE_color) || defined(ENABLE_brightness)
|
||||
// Divide premultiplied alpha values for proper color processing
|
||||
// Add epsilon to avoid dividing by 0 for fully transparent pixels
|
||||
gl_FragColor.rgb = clamp(gl_FragColor.rgb / (gl_FragColor.a + epsilon), 0.0, 1.0);
|
||||
|
||||
#ifdef ENABLE_color
|
||||
{
|
||||
vec3 hsv = convertRGB2HSV(gl_FragColor.xyz);
|
||||
|
||||
// this code forces grayscale values to be slightly saturated
|
||||
// so that some slight change of hue will be visible
|
||||
const float minLightness = 0.11 / 2.0;
|
||||
const float minSaturation = 0.09;
|
||||
if (hsv.z < minLightness) hsv = vec3(0.0, 1.0, minLightness);
|
||||
else if (hsv.y < minSaturation) hsv = vec3(0.0, minSaturation, hsv.z);
|
||||
|
||||
hsv.x = mod(hsv.x + u_color, 1.0);
|
||||
if (hsv.x < 0.0) hsv.x += 1.0;
|
||||
|
||||
gl_FragColor.rgb = convertHSV2RGB(hsv);
|
||||
}
|
||||
#endif // ENABLE_color
|
||||
|
||||
#ifdef ENABLE_brightness
|
||||
gl_FragColor.rgb = clamp(gl_FragColor.rgb + vec3(u_brightness), vec3(0), vec3(1));
|
||||
#endif // ENABLE_brightness
|
||||
|
||||
// Re-multiply color values
|
||||
gl_FragColor.rgb *= gl_FragColor.a + epsilon;
|
||||
|
||||
#endif // defined(ENABLE_color) || defined(ENABLE_brightness)
|
||||
|
||||
#ifdef ENABLE_ghost
|
||||
gl_FragColor *= u_ghost;
|
||||
#endif // ENABLE_ghost
|
||||
|
||||
#ifdef DRAW_MODE_silhouette
|
||||
// Discard fully transparent pixels for stencil test
|
||||
if (gl_FragColor.a == 0.0) {
|
||||
discard;
|
||||
}
|
||||
// switch to u_silhouetteColor only AFTER the alpha test
|
||||
gl_FragColor = u_silhouetteColor;
|
||||
#else // DRAW_MODE_silhouette
|
||||
|
||||
#ifdef DRAW_MODE_colorMask
|
||||
vec3 maskDistance = abs(gl_FragColor.rgb - u_colorMask);
|
||||
vec3 colorMaskTolerance = vec3(u_colorMaskTolerance, u_colorMaskTolerance, u_colorMaskTolerance);
|
||||
if (any(greaterThan(maskDistance, colorMaskTolerance)))
|
||||
{
|
||||
discard;
|
||||
}
|
||||
#endif // DRAW_MODE_colorMask
|
||||
#endif // DRAW_MODE_silhouette
|
||||
|
||||
#ifdef DRAW_MODE_straightAlpha
|
||||
// Un-premultiply alpha.
|
||||
gl_FragColor.rgb /= gl_FragColor.a + epsilon;
|
||||
#endif
|
||||
|
||||
#endif // !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background))
|
||||
|
||||
#ifdef DRAW_MODE_line
|
||||
// Maaaaagic antialiased-line-with-round-caps shader.
|
||||
|
||||
// "along-the-lineness". This increases parallel to the line.
|
||||
// It goes from negative before the start point, to 0.5 through the start to the end, then ramps up again
|
||||
// past the end point.
|
||||
float d = ((v_texCoord.x - clamp(v_texCoord.x, 0.0, v_lineLength)) * 0.5) + 0.5;
|
||||
|
||||
// Distance from (0.5, 0.5) to (d, the perpendicular coordinate). When we're in the middle of the line,
|
||||
// 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.
|
||||
// For the "caps", d will ramp down/up, giving us rounding.
|
||||
// See https://www.youtube.com/watch?v=PMltMdi1Wzg for a rough outline of the technique used to round the lines.
|
||||
float line = distance(vec2(0.5), vec2(d, v_texCoord.y)) * 2.0;
|
||||
// Expand out the line by its thickness.
|
||||
line -= ((v_lineThickness - 1.0) * 0.5);
|
||||
// Because "distance to the center of the line" decreases the closer we get to the line, but we want more opacity
|
||||
// the closer we are to the line, invert it.
|
||||
gl_FragColor = v_lineColor * clamp(1.0 - line, 0.0, 1.0);
|
||||
#endif // DRAW_MODE_line
|
||||
|
||||
#ifdef DRAW_MODE_background
|
||||
gl_FragColor = u_backgroundColor;
|
||||
#endif
|
||||
}
|
||||
82
scratch-render/src/shaders/sprite.vert
Normal file
82
scratch-render/src/shaders/sprite.vert
Normal file
@@ -0,0 +1,82 @@
|
||||
precision mediump float;
|
||||
|
||||
#ifdef DRAW_MODE_line
|
||||
uniform vec2 u_stageSize;
|
||||
attribute vec2 a_lineThicknessAndLength;
|
||||
attribute vec4 a_penPoints;
|
||||
attribute vec4 a_lineColor;
|
||||
|
||||
varying vec4 v_lineColor;
|
||||
varying float v_lineThickness;
|
||||
varying float v_lineLength;
|
||||
varying vec4 v_penPoints;
|
||||
|
||||
// Add this to divisors to prevent division by 0, which results in NaNs propagating through calculations.
|
||||
// Smaller values can cause problems on some mobile devices.
|
||||
const float epsilon = 1e-3;
|
||||
#endif
|
||||
|
||||
#if !(defined(DRAW_MODE_line) || defined(DRAW_MODE_background))
|
||||
uniform mat4 u_projectionMatrix;
|
||||
uniform mat4 u_modelMatrix;
|
||||
attribute vec2 a_texCoord;
|
||||
#endif
|
||||
|
||||
attribute vec2 a_position;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
#ifdef DRAW_MODE_line
|
||||
// Calculate a rotated ("tight") bounding box around the two pen points.
|
||||
// Yes, we're doing this 6 times (once per vertex), but on actual GPU hardware,
|
||||
// it's still faster than doing it in JS combined with the cost of uniformMatrix4fv.
|
||||
|
||||
// Expand line bounds by sqrt(2) / 2 each side-- this ensures that all antialiased pixels
|
||||
// fall within the quad, even at a 45-degree diagonal
|
||||
vec2 position = a_position;
|
||||
float expandedRadius = (a_lineThicknessAndLength.x * 0.5) + 1.4142135623730951;
|
||||
|
||||
// The X coordinate increases along the length of the line. It's 0 at the center of the origin point
|
||||
// and is in pixel-space (so at n pixels along the line, its value is n).
|
||||
v_texCoord.x = mix(0.0, a_lineThicknessAndLength.y + (expandedRadius * 2.0), a_position.x) - expandedRadius;
|
||||
// The Y coordinate is perpendicular to the line. It's also in pixel-space.
|
||||
v_texCoord.y = ((a_position.y - 0.5) * expandedRadius) + 0.5;
|
||||
|
||||
position.x *= a_lineThicknessAndLength.y + (2.0 * expandedRadius);
|
||||
position.y *= 2.0 * expandedRadius;
|
||||
|
||||
// 1. Center around first pen point
|
||||
position -= expandedRadius;
|
||||
|
||||
// 2. Rotate quad to line angle
|
||||
vec2 pointDiff = a_penPoints.zw;
|
||||
// Ensure line has a nonzero length so it's rendered properly
|
||||
// As long as either component is nonzero, the line length will be nonzero
|
||||
// If the line is zero-length, give it a bit of horizontal length
|
||||
pointDiff.x = (abs(pointDiff.x) < epsilon && abs(pointDiff.y) < epsilon) ? epsilon : pointDiff.x;
|
||||
// The `normalized` vector holds rotational values equivalent to sine/cosine
|
||||
// We're applying the standard rotation matrix formula to the position to rotate the quad to the line angle
|
||||
// pointDiff can hold large values so we must divide by u_lineLength instead of calling GLSL's normalize function:
|
||||
// https://asawicki.info/news_1596_watch_out_for_reduced_precision_normalizelength_in_opengl_es
|
||||
vec2 normalized = pointDiff / max(a_lineThicknessAndLength.y, epsilon);
|
||||
position = mat2(normalized.x, normalized.y, -normalized.y, normalized.x) * position;
|
||||
|
||||
// 3. Translate quad
|
||||
position += a_penPoints.xy;
|
||||
|
||||
// 4. Apply view transform
|
||||
position *= 2.0 / u_stageSize;
|
||||
gl_Position = vec4(position, 0, 1);
|
||||
|
||||
v_lineColor = a_lineColor;
|
||||
v_lineThickness = a_lineThicknessAndLength.x;
|
||||
v_lineLength = a_lineThicknessAndLength.y;
|
||||
v_penPoints = a_penPoints;
|
||||
#elif defined(DRAW_MODE_background)
|
||||
gl_Position = vec4(a_position * 2.0, 0, 1);
|
||||
#else
|
||||
gl_Position = u_projectionMatrix * u_modelMatrix * vec4(a_position, 0, 1);
|
||||
v_texCoord = a_texCoord;
|
||||
#endif
|
||||
}
|
||||
41
scratch-render/src/util/canvas-measurement-provider.js
Normal file
41
scratch-render/src/util/canvas-measurement-provider.js
Normal file
@@ -0,0 +1,41 @@
|
||||
class CanvasMeasurementProvider {
|
||||
/**
|
||||
* @param {CanvasRenderingContext2D} ctx - provides a canvas rendering context
|
||||
* with 'font' set to the text style of the text to be wrapped.
|
||||
*/
|
||||
constructor (ctx) {
|
||||
this._ctx = ctx;
|
||||
this._cache = {};
|
||||
}
|
||||
|
||||
|
||||
// We don't need to set up or tear down anything here. Should these be removed altogether?
|
||||
|
||||
/**
|
||||
* Called by the TextWrapper before a batch of zero or more calls to measureText().
|
||||
*/
|
||||
beginMeasurementSession () {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the TextWrapper after a batch of zero or more calls to measureText().
|
||||
*/
|
||||
endMeasurementSession () {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure a whole string as one unit.
|
||||
* @param {string} text - the text to measure.
|
||||
* @returns {number} - the length of the string.
|
||||
*/
|
||||
measureText (text) {
|
||||
if (!this._cache[text]) {
|
||||
this._cache[text] = this._ctx.measureText(text).width;
|
||||
}
|
||||
return this._cache[text];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CanvasMeasurementProvider;
|
||||
97
scratch-render/src/util/color-conversions.js
Normal file
97
scratch-render/src/util/color-conversions.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Converts an RGB color value to HSV. Conversion formula
|
||||
* adapted from http://lolengine.net/blog/2013/01/13/fast-rgb-to-hsv.
|
||||
* Assumes r, g, and b are in the range [0, 255] and
|
||||
* returns h, s, and v in the range [0, 1].
|
||||
*
|
||||
* @param {Array<number>} rgb The RGB color value
|
||||
* @param {number} rgb.r The red color value
|
||||
* @param {number} rgb.g The green color value
|
||||
* @param {number} rgb.b The blue color value
|
||||
* @param {Array<number>} dst The array to store the HSV values in
|
||||
* @return {Array<number>} The `dst` array passed in
|
||||
*/
|
||||
const rgbToHsv = ([r, g, b], dst) => {
|
||||
let K = 0.0;
|
||||
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
let tmp = 0;
|
||||
|
||||
if (g < b) {
|
||||
tmp = g;
|
||||
g = b;
|
||||
b = tmp;
|
||||
|
||||
K = -1;
|
||||
}
|
||||
|
||||
if (r < g) {
|
||||
tmp = r;
|
||||
r = g;
|
||||
g = tmp;
|
||||
|
||||
K = (-2 / 6) - K;
|
||||
}
|
||||
|
||||
const chroma = r - Math.min(g, b);
|
||||
const h = Math.abs(K + ((g - b) / ((6 * chroma) + Number.EPSILON)));
|
||||
const s = chroma / (r + Number.EPSILON);
|
||||
const v = r;
|
||||
|
||||
dst[0] = h;
|
||||
dst[1] = s;
|
||||
dst[2] = v;
|
||||
|
||||
return dst;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an HSV color value to RGB. Conversion formula
|
||||
* adapted from https://gist.github.com/mjackson/5311256.
|
||||
* Assumes h, s, and v are contained in the set [0, 1] and
|
||||
* returns r, g, and b in the set [0, 255].
|
||||
*
|
||||
* @param {Array<number>} hsv The HSV color value
|
||||
* @param {number} hsv.h The hue
|
||||
* @param {number} hsv.s The saturation
|
||||
* @param {number} hsv.v The value
|
||||
* @param {Uint8Array|Uint8ClampedArray} dst The array to store the RGB values in
|
||||
* @return {Uint8Array|Uint8ClampedArray} The `dst` array passed in
|
||||
*/
|
||||
const hsvToRgb = ([h, s, v], dst) => {
|
||||
if (s === 0) {
|
||||
dst[0] = dst[1] = dst[2] = (v * 255) + 0.5;
|
||||
return dst;
|
||||
}
|
||||
|
||||
// keep hue in [0,1) so the `switch(i)` below only needs 6 cases (0-5)
|
||||
h %= 1;
|
||||
const i = (h * 6) | 0;
|
||||
const f = (h * 6) - i;
|
||||
const p = v * (1 - s);
|
||||
const q = v * (1 - (s * f));
|
||||
const t = v * (1 - (s * (1 - f)));
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
switch (i) {
|
||||
case 0: r = v; g = t; b = p; break;
|
||||
case 1: r = q; g = v; b = p; break;
|
||||
case 2: r = p; g = v; b = t; break;
|
||||
case 3: r = p; g = q; b = v; break;
|
||||
case 4: r = t; g = p; b = v; break;
|
||||
case 5: r = v; g = p; b = q; break;
|
||||
}
|
||||
|
||||
// Add 0.5 in order to round. Setting integer TypedArray elements implicitly floors.
|
||||
dst[0] = (r * 255) + 0.5;
|
||||
dst[1] = (g * 255) + 0.5;
|
||||
dst[2] = (b * 255) + 0.5;
|
||||
return dst;
|
||||
};
|
||||
|
||||
module.exports = {rgbToHsv, hsvToRgb};
|
||||
4
scratch-render/src/util/log.js
Normal file
4
scratch-render/src/util/log.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const nanolog = require('@turbowarp/nanolog');
|
||||
nanolog.enable();
|
||||
|
||||
module.exports = nanolog('scratch-render');
|
||||
112
scratch-render/src/util/text-wrapper.js
Normal file
112
scratch-render/src/util/text-wrapper.js
Normal file
@@ -0,0 +1,112 @@
|
||||
const LineBreaker = require('!ify-loader!linebreak');
|
||||
const GraphemeBreaker = require('!ify-loader!grapheme-breaker');
|
||||
|
||||
/**
|
||||
* Tell this text wrapper to use a specific measurement provider.
|
||||
* @typedef {object} MeasurementProvider - the new measurement provider.
|
||||
* @property {Function} beginMeasurementSession - this will be called before a batch of measurements are made.
|
||||
* Optionally, this function may return an object to be provided to the endMeasurementSession function.
|
||||
* @property {Function} measureText - this will be called each time a piece of text must be measured.
|
||||
* @property {Function} endMeasurementSession - this will be called after a batch of measurements is finished.
|
||||
* It will be passed whatever value beginMeasurementSession returned, if any.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility to wrap text across several lines, respecting Unicode grapheme clusters and, when possible, Unicode line
|
||||
* break opportunities.
|
||||
* Reference material:
|
||||
* - Unicode Standard Annex #14: http://unicode.org/reports/tr14/
|
||||
* - Unicode Standard Annex #29: http://unicode.org/reports/tr29/
|
||||
* - "JavaScript has a Unicode problem" by Mathias Bynens: https://mathiasbynens.be/notes/javascript-unicode
|
||||
*/
|
||||
class TextWrapper {
|
||||
/**
|
||||
* Construct a text wrapper which will measure text using the specified measurement provider.
|
||||
* @param {MeasurementProvider} measurementProvider - a helper object to provide text measurement services.
|
||||
*/
|
||||
constructor (measurementProvider) {
|
||||
this._measurementProvider = measurementProvider;
|
||||
this._cache = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the provided text into lines restricted to a maximum width. See Unicode Standard Annex (UAX) #14.
|
||||
* @param {number} maxWidth - the maximum allowed width of a line.
|
||||
* @param {string} text - the text to be wrapped. Will be split on whitespace.
|
||||
* @returns {Array.<string>} an array containing the wrapped lines of text.
|
||||
*/
|
||||
wrapText (maxWidth, text) {
|
||||
// Normalize to canonical composition (see Unicode Standard Annex (UAX) #15)
|
||||
text = text.normalize();
|
||||
|
||||
const cacheKey = `${maxWidth}-${text}`;
|
||||
if (this._cache[cacheKey]) {
|
||||
return this._cache[cacheKey];
|
||||
}
|
||||
|
||||
const measurementSession = this._measurementProvider.beginMeasurementSession();
|
||||
|
||||
const breaker = new LineBreaker(text);
|
||||
let lastPosition = 0;
|
||||
let nextBreak;
|
||||
let currentLine = null;
|
||||
const lines = [];
|
||||
|
||||
while ((nextBreak = breaker.nextBreak())) {
|
||||
const word = text.slice(lastPosition, nextBreak.position).replace(/\n+$/, '');
|
||||
|
||||
let proposedLine = (currentLine || '').concat(word);
|
||||
let proposedLineWidth = this._measurementProvider.measureText(proposedLine);
|
||||
|
||||
if (proposedLineWidth > maxWidth) {
|
||||
// The next word won't fit on this line. Will it fit on a line by itself?
|
||||
const wordWidth = this._measurementProvider.measureText(word);
|
||||
if (wordWidth > maxWidth) {
|
||||
// The next word can't even fit on a line by itself. Consume it one grapheme cluster at a time.
|
||||
let lastCluster = 0;
|
||||
let nextCluster;
|
||||
while (lastCluster !== (nextCluster = GraphemeBreaker.nextBreak(word, lastCluster))) {
|
||||
const cluster = word.substring(lastCluster, nextCluster);
|
||||
proposedLine = (currentLine || '').concat(cluster);
|
||||
proposedLineWidth = this._measurementProvider.measureText(proposedLine);
|
||||
if ((currentLine === null) || (proposedLineWidth <= maxWidth)) {
|
||||
// first cluster of a new line or the cluster fits
|
||||
currentLine = proposedLine;
|
||||
} else {
|
||||
// no more can fit
|
||||
lines.push(currentLine);
|
||||
currentLine = cluster;
|
||||
}
|
||||
lastCluster = nextCluster;
|
||||
}
|
||||
} else {
|
||||
// The next word can fit on the next line. Finish the current line and move on.
|
||||
if (currentLine !== null) lines.push(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
} else {
|
||||
// The next word fits on this line. Just keep going.
|
||||
currentLine = proposedLine;
|
||||
}
|
||||
|
||||
// Did we find a \n or similar?
|
||||
if (nextBreak.required) {
|
||||
if (currentLine !== null) lines.push(currentLine);
|
||||
currentLine = null;
|
||||
}
|
||||
|
||||
lastPosition = nextBreak.position;
|
||||
}
|
||||
|
||||
currentLine = currentLine || '';
|
||||
if (currentLine.length > 0 || lines.length === 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
this._cache[cacheKey] = lines;
|
||||
this._measurementProvider.endMeasurementSession(measurementSession);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TextWrapper;
|
||||
23
scratch-render/test/fixtures/MockSkin.js
vendored
Normal file
23
scratch-render/test/fixtures/MockSkin.js
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
const Skin = require('../../src/Skin');
|
||||
|
||||
class MockSkin extends Skin {
|
||||
set size (dimensions) {
|
||||
this.dimensions = dimensions;
|
||||
}
|
||||
|
||||
get size () {
|
||||
return this.dimensions || [0, 0];
|
||||
}
|
||||
|
||||
set rotationCenter (center) {
|
||||
this._rotationCenter[0] = center[0];
|
||||
this._rotationCenter[1] = center[1];
|
||||
this.emitWasAltered();
|
||||
}
|
||||
|
||||
get rotationCenter () {
|
||||
return this._rotationCenter;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MockSkin;
|
||||
54
scratch-render/test/helper/page-util.js
Normal file
54
scratch-render/test/helper/page-util.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/* global VirtualMachine, ScratchStorage, ScratchSVGRenderer */
|
||||
/* eslint-env browser */
|
||||
|
||||
// Wait for all SVG skins to be loaded.
|
||||
// TODO: this is extremely janky and should be removed once vm.loadProject waits for SVG skins to load
|
||||
// https://github.com/LLK/scratch-render/issues/563
|
||||
window.waitForSVGSkinLoad = renderer => new Promise(resolve => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let interval;
|
||||
|
||||
const waitInner = () => {
|
||||
let numSVGSkins = 0;
|
||||
let numLoadedSVGSkins = 0;
|
||||
for (const skin of renderer._allSkins) {
|
||||
if (skin.constructor.name !== 'SVGSkin') continue;
|
||||
numSVGSkins++;
|
||||
if (skin._svgImage.complete) numLoadedSVGSkins++;
|
||||
}
|
||||
|
||||
if (numSVGSkins === numLoadedSVGSkins) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
interval = setInterval(waitInner, 1);
|
||||
});
|
||||
|
||||
window.loadFileInputIntoVM = (fileInput, vm, render) => {
|
||||
const reader = new FileReader();
|
||||
return new Promise(resolve => {
|
||||
reader.onload = () => {
|
||||
vm.start();
|
||||
vm.loadProject(reader.result)
|
||||
.then(() => window.waitForSVGSkinLoad(render))
|
||||
.then(() => {
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
reader.readAsArrayBuffer(fileInput.files[0]);
|
||||
});
|
||||
};
|
||||
|
||||
window.initVM = render => {
|
||||
const vm = new VirtualMachine();
|
||||
const storage = new ScratchStorage();
|
||||
|
||||
vm.attachStorage(storage);
|
||||
vm.attachRenderer(render);
|
||||
vm.attachV2SVGAdapter(ScratchSVGRenderer.V2SVGAdapter);
|
||||
vm.attachV2BitmapAdapter(new ScratchSVGRenderer.BitmapAdapter());
|
||||
|
||||
return vm;
|
||||
};
|
||||
69
scratch-render/test/integration/cpu-render.html
Normal file
69
scratch-render/test/integration/cpu-render.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<body>
|
||||
<script src="../../node_modules/scratch-vm/dist/web/scratch-vm.js"></script>
|
||||
<script src="../../node_modules/scratch-storage/dist/web/scratch-storage.js"></script>
|
||||
<script src="../../node_modules/@turbowarp/scratch-svg-renderer/dist/web/scratch-svg-renderer.js"></script>
|
||||
<script src="../helper/page-util.js"></script>
|
||||
<!-- note: this uses the BUILT version of scratch-render! make sure to npm run build -->
|
||||
<script src="../../dist/web/scratch-render.js"></script>
|
||||
|
||||
<canvas id="test" width="480" height="360"></canvas>
|
||||
<canvas id="cpu" width="480" height="360"></canvas>
|
||||
<br/>
|
||||
<canvas id="merge" width="480" height="360"></canvas>
|
||||
<input type="file" id="file" name="file">
|
||||
|
||||
<script>
|
||||
// These variables are going to be available in the "window global" intentionally.
|
||||
// Allows you easy access to debug with `vm.greenFlag()` etc.
|
||||
window.devicePixelRatio = 1;
|
||||
const gpuCanvas = document.getElementById('test');
|
||||
var render = new ScratchRender(gpuCanvas);
|
||||
var vm = initVM(render);
|
||||
|
||||
const fileInput = document.getElementById('file');
|
||||
const loadFile = loadFileInputIntoVM.bind(null, fileInput, vm, render);
|
||||
fileInput.addEventListener('change', e => {
|
||||
loadFile()
|
||||
.then(() => {
|
||||
vm.greenFlag();
|
||||
setTimeout(() => {
|
||||
renderCpu();
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
const cpuCanvas = document.getElementById('cpu');
|
||||
const cpuCtx = cpuCanvas.getContext('2d');
|
||||
const cpuImageData = cpuCtx.getImageData(0, 0, cpuCanvas.width, cpuCanvas.height);
|
||||
function renderCpu() {
|
||||
cpuImageData.data.fill(255);
|
||||
const drawBits = render._drawList.map(id => {
|
||||
const drawable = render._allDrawables[id];
|
||||
if (!(drawable._visible && drawable.skin)) {
|
||||
return;
|
||||
}
|
||||
drawable.updateCPURenderAttributes();
|
||||
return { id, drawable };
|
||||
}).reverse().filter(Boolean);
|
||||
const color = new Uint8ClampedArray(3);
|
||||
for (let x = -239; x <= 240; x++) {
|
||||
for (let y = -180; y< 180; y++) {
|
||||
render.constructor.sampleColor3b([x, y], drawBits, color);
|
||||
const offset = (((179-y) * 480) + 239 + x) * 4
|
||||
cpuImageData.data.set(color, offset);
|
||||
}
|
||||
}
|
||||
cpuCtx.putImageData(cpuImageData, 0, 0);
|
||||
|
||||
const merge = document.getElementById('merge');
|
||||
const ctx = merge.getContext('2d');
|
||||
ctx.drawImage(gpuCanvas, 0, 0);
|
||||
const gpuImageData = ctx.getImageData(0, 0, 480, 360);
|
||||
for (let x=0; x<gpuImageData.data.length; x++) {
|
||||
gpuImageData.data[x] = 255 - Math.abs(gpuImageData.data[x] - cpuImageData.data[x]);
|
||||
}
|
||||
|
||||
ctx.putImageData(gpuImageData, 0, 0);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
28
scratch-render/test/integration/index.html
Normal file
28
scratch-render/test/integration/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<body>
|
||||
<script src="../../node_modules/scratch-vm/dist/web/scratch-vm.js"></script>
|
||||
<script src="../../node_modules/scratch-storage/dist/web/scratch-storage.js"></script>
|
||||
<script src="../../node_modules/@turbowarp/scratch-svg-renderer/dist/web/scratch-svg-renderer.js"></script>
|
||||
<script src="../helper/page-util.js"></script>
|
||||
<!-- note: this uses the BUILT version of scratch-render! make sure to npm run build -->
|
||||
<script src="../../dist/web/scratch-render.js"></script>
|
||||
|
||||
<canvas id="test" width="480" height="360" style="width: 480px"></canvas>
|
||||
<input type="file" id="file" name="file">
|
||||
|
||||
<script>
|
||||
// These variables are going to be available in the "window global" intentionally.
|
||||
// Allows you easy access to debug with `vm.greenFlag()` etc.
|
||||
window.devicePixelRatio = 1;
|
||||
|
||||
var canvas = document.getElementById('test');
|
||||
var render = new ScratchRender(canvas);
|
||||
var vm = initVM(render);
|
||||
var mockMouse = data => vm.runtime.postIOData('mouse', {
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
...data,
|
||||
});
|
||||
|
||||
const loadFile = loadFileInputIntoVM.bind(null, document.getElementById('file'), vm, render);
|
||||
</script>
|
||||
</body>
|
||||
111
scratch-render/test/integration/pick-tests.js
Normal file
111
scratch-render/test/integration/pick-tests.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/* global vm, render */
|
||||
const {chromium} = require('playwright-chromium');
|
||||
const test = require('tap').test;
|
||||
const path = require('path');
|
||||
|
||||
const indexHTML = path.resolve(__dirname, 'index.html');
|
||||
const testDir = (...args) => path.resolve(__dirname, 'pick-tests', ...args);
|
||||
|
||||
const runFile = async (file, action, page, script) => {
|
||||
// start each test by going to the index.html, and loading the scratch file
|
||||
await page.goto(`file://${indexHTML}`);
|
||||
const fileInput = await page.$('#file');
|
||||
await fileInput.setInputFiles(testDir(file));
|
||||
|
||||
await page.evaluate(() =>
|
||||
// `loadFile` is defined on the page itself.
|
||||
// eslint-disable-next-line no-undef
|
||||
loadFile()
|
||||
);
|
||||
return page.evaluate(`(function () {return (${script})(${action});})()`);
|
||||
};
|
||||
|
||||
// immediately invoked async function to let us wait for each test to finish before starting the next.
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
const testOperation = async function (name, action, expect) {
|
||||
await test(name, async t => {
|
||||
|
||||
const results = await runFile('test-mouse-touch.sb2', action, page, boundAction => {
|
||||
vm.greenFlag();
|
||||
const sendResults = [];
|
||||
|
||||
const idToTargetName = id => {
|
||||
const target = vm.runtime.targets.find(tar => tar.drawableID === id);
|
||||
if (!target) {
|
||||
return `[Unknown drawableID: ${id}]`;
|
||||
}
|
||||
return target.sprite.name;
|
||||
};
|
||||
const sprite = vm.runtime.targets.find(target => target.sprite.name === 'Sprite1');
|
||||
|
||||
boundAction({
|
||||
sendResults,
|
||||
idToTargetName,
|
||||
render,
|
||||
sprite
|
||||
});
|
||||
return sendResults;
|
||||
});
|
||||
|
||||
t.plan(expect.length);
|
||||
for (let x = 0; x < expect.length; x++) {
|
||||
t.deepEqual(results[x], expect[x], expect[x][0]);
|
||||
}
|
||||
t.end();
|
||||
});
|
||||
};
|
||||
|
||||
const tests = [
|
||||
{
|
||||
name: 'pick Sprite1',
|
||||
action: ({sendResults, render, idToTargetName}) => {
|
||||
sendResults.push(['center', idToTargetName(render.pick(360, 180))]);
|
||||
},
|
||||
expect: [['center', 'Sprite1']]
|
||||
},
|
||||
{
|
||||
name: 'pick Stage',
|
||||
action: ({sendResults, render, idToTargetName}) => {
|
||||
sendResults.push(['left', idToTargetName(render.pick(320, 180))]);
|
||||
},
|
||||
expect: [['left', 'Stage']]
|
||||
},
|
||||
{
|
||||
name: 'touching Sprite1',
|
||||
action: ({sprite, sendResults, render}) => {
|
||||
sendResults.push(['over', render.drawableTouching(sprite.drawableID, 360, 180)]);
|
||||
},
|
||||
expect: [['over', true]]
|
||||
},
|
||||
{
|
||||
name: 'pick Stage through hidden Sprite1',
|
||||
action: ({sprite, sendResults, render, idToTargetName}) => {
|
||||
sprite.setVisible(false);
|
||||
sendResults.push(['hidden sprite pick center', idToTargetName(render.pick(360, 180))]);
|
||||
},
|
||||
expect: [['hidden sprite pick center', 'Stage']]
|
||||
},
|
||||
{
|
||||
name: 'touching hidden Sprite1',
|
||||
action: ({sprite, sendResults, render}) => {
|
||||
sprite.setVisible(false);
|
||||
sendResults.push(['hidden over', render.drawableTouching(sprite.drawableID, 360, 180)]);
|
||||
},
|
||||
expect: [['hidden over', true]]
|
||||
}
|
||||
];
|
||||
for (const {name, action, expect} of tests) {
|
||||
await testOperation(name, action, expect);
|
||||
}
|
||||
|
||||
// close the browser window we used
|
||||
await browser.close();
|
||||
})().catch(err => {
|
||||
// Handle promise rejections by exiting with a nonzero code to ensure that tests don't erroneously pass
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
scratch-render/test/integration/pick-tests/test-mouse-touch.sb2
Normal file
BIN
scratch-render/test/integration/pick-tests/test-mouse-touch.sb2
Normal file
Binary file not shown.
136
scratch-render/test/integration/scratch-tests.js
Normal file
136
scratch-render/test/integration/scratch-tests.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/* global vm */
|
||||
const {chromium} = require('playwright-chromium');
|
||||
const test = require('tap').test;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const allGpuModes = ['ForceCPU', 'ForceGPU', 'Automatic'];
|
||||
|
||||
const indexHTML = path.resolve(__dirname, 'index.html');
|
||||
const testDir = (...args) => path.resolve(__dirname, 'scratch-tests', ...args);
|
||||
|
||||
const checkOneGpuMode = (t, says) => {
|
||||
// Map string messages to tap reporting methods. This will be used
|
||||
// with events from scratch's runtime emitted on block instructions.
|
||||
let didPlan = false;
|
||||
let didEnd = false;
|
||||
const reporters = {
|
||||
comment (message) {
|
||||
t.comment(message);
|
||||
},
|
||||
pass (reason) {
|
||||
t.pass(reason);
|
||||
},
|
||||
fail (reason) {
|
||||
t.fail(reason);
|
||||
},
|
||||
plan (count) {
|
||||
didPlan = true;
|
||||
t.plan(Number(count));
|
||||
},
|
||||
end () {
|
||||
didEnd = true;
|
||||
t.end();
|
||||
}
|
||||
};
|
||||
|
||||
// loop over each "SAY" we caught from the VM and use the reporters
|
||||
says.forEach(text => {
|
||||
// first word of the say is going to be a "command"
|
||||
const command = text.split(/\s+/, 1)[0].toLowerCase();
|
||||
if (reporters[command]) {
|
||||
return reporters[command](text.substring(command.length).trim());
|
||||
}
|
||||
|
||||
// Default to a comment with the full text if we didn't match
|
||||
// any command prefix
|
||||
return reporters.comment(text);
|
||||
});
|
||||
|
||||
if (!didPlan) {
|
||||
t.comment('did not say "plan NUMBER_OF_TESTS"');
|
||||
}
|
||||
|
||||
// End must be called so that tap knows the test is done. If
|
||||
// the test has a SAY "end" block but that block did not
|
||||
// execute, this explicit failure will raise that issue so
|
||||
// it can be resolved.
|
||||
if (!didEnd) {
|
||||
t.fail('did not say "end"');
|
||||
t.end();
|
||||
}
|
||||
};
|
||||
|
||||
const testFile = async (file, page) => {
|
||||
// start each test by going to the index.html, and loading the scratch file
|
||||
await page.goto(`file://${indexHTML}`);
|
||||
const fileInput = await page.$('#file');
|
||||
await fileInput.setInputFiles(testDir(file));
|
||||
await page.evaluate(() =>
|
||||
// `loadFile` is defined on the page itself.
|
||||
// eslint-disable-next-line no-undef
|
||||
loadFile()
|
||||
);
|
||||
const says = await page.evaluate(async useGpuModes => {
|
||||
// This function is run INSIDE the integration chrome browser via some
|
||||
// injection and .toString() magic. We can return some "simple data"
|
||||
// back across as a promise, so we will just log all the says that happen
|
||||
// for parsing after.
|
||||
|
||||
// this becomes the `says` in the outer scope
|
||||
const allMessages = {};
|
||||
const TIMEOUT = 5000;
|
||||
|
||||
vm.runtime.on('SAY', (_, __, message) => {
|
||||
const messages = allMessages[vm.renderer._useGpuMode];
|
||||
messages.push(message);
|
||||
});
|
||||
|
||||
for (const useGpuMode of useGpuModes) {
|
||||
const messages = allMessages[useGpuMode] = [];
|
||||
|
||||
vm.renderer.setUseGpuMode(useGpuMode);
|
||||
vm.greenFlag();
|
||||
const startTime = Date.now();
|
||||
|
||||
// wait for all threads to complete before moving on to the next mode
|
||||
while (vm.runtime.threads.some(thread => vm.runtime.isActiveThread(thread))) {
|
||||
if ((Date.now() - startTime) >= TIMEOUT) {
|
||||
// if we push the message after end, the failure from tap is not very useful:
|
||||
// "not ok test after end() was called"
|
||||
messages.unshift(`fail Threads still running after ${TIMEOUT}ms`);
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
return allMessages;
|
||||
}, allGpuModes);
|
||||
|
||||
for (const gpuMode of allGpuModes) {
|
||||
test(`File: ${file}, GPU Mode: ${gpuMode}`, t => checkOneGpuMode(t, says[gpuMode]));
|
||||
}
|
||||
};
|
||||
|
||||
// immediately invoked async function to let us wait for each test to finish before starting the next.
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
const files = fs.readdirSync(testDir())
|
||||
.filter(uri => uri.endsWith('.sb2') || uri.endsWith('.sb3'));
|
||||
|
||||
for (const file of files) {
|
||||
await testFile(file, page);
|
||||
}
|
||||
|
||||
// close the browser window we used
|
||||
await browser.close();
|
||||
})().catch(err => {
|
||||
// Handle promise rejections by exiting with a nonzero code to ensure that tests don't erroneously pass
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
BIN
scratch-render/test/integration/scratch-tests/clear-color.sb3
Normal file
BIN
scratch-render/test/integration/scratch-tests/clear-color.sb3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
scratch-render/test/integration/scratch-tests/fencing-bounds.sb3
Normal file
BIN
scratch-render/test/integration/scratch-tests/fencing-bounds.sb3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
61
scratch-render/test/integration/skin-size-tests.js
Normal file
61
scratch-render/test/integration/skin-size-tests.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/* global render, ImageData */
|
||||
const {chromium} = require('playwright-chromium');
|
||||
const test = require('tap').test;
|
||||
const path = require('path');
|
||||
|
||||
const indexHTML = path.resolve(__dirname, 'index.html');
|
||||
|
||||
// immediately invoked async function to let us wait for each test to finish before starting the next.
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(`file://${indexHTML}`);
|
||||
|
||||
await test('SVG skin size set properly', async t => {
|
||||
t.plan(1);
|
||||
const skinSize = await page.evaluate(() => {
|
||||
const skinID = render.createSVGSkin(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 100"></svg>`);
|
||||
return render.getSkinSize(skinID);
|
||||
});
|
||||
t.same(skinSize, [50, 100]);
|
||||
});
|
||||
|
||||
await test('Bitmap skin size set correctly', async t => {
|
||||
t.plan(1);
|
||||
const skinSize = await page.evaluate(() => {
|
||||
// Bitmap costumes are double resolution, so double the ImageData size
|
||||
const skinID = render.createBitmapSkin(new ImageData(100, 200), 2);
|
||||
return render.getSkinSize(skinID);
|
||||
});
|
||||
t.same(skinSize, [50, 100]);
|
||||
});
|
||||
|
||||
await test('Pen skin size set correctly', async t => {
|
||||
t.plan(1);
|
||||
const skinSize = await page.evaluate(() => {
|
||||
const skinID = render.createPenSkin();
|
||||
return render.getSkinSize(skinID);
|
||||
});
|
||||
const nativeSize = await page.evaluate(() => render.getNativeSize());
|
||||
t.same(skinSize, nativeSize);
|
||||
});
|
||||
|
||||
await test('Text bubble skin size set correctly', async t => {
|
||||
t.plan(1);
|
||||
const skinSize = await page.evaluate(() => {
|
||||
const skinID = render.createTextSkin('say', 'Hello', false);
|
||||
return render.getSkinSize(skinID);
|
||||
});
|
||||
// The subtleties in font rendering may cause the size of the text bubble to vary, so just make sure it's not 0
|
||||
t.notSame(skinSize, [0, 0]);
|
||||
});
|
||||
|
||||
// close the browser window we used
|
||||
await browser.close();
|
||||
})().catch(err => {
|
||||
// Handle promise rejections by exiting with a nonzero code to ensure that tests don't erroneously pass
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
48
scratch-render/test/unit/ColorConversionTests.js
Normal file
48
scratch-render/test/unit/ColorConversionTests.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const {test, Test} = require('tap');
|
||||
|
||||
const {rgbToHsv, hsvToRgb} = require('../../src/util/color-conversions');
|
||||
|
||||
Test.prototype.addAssert('colorsAlmostEqual', 2, function (found, wanted, message, extra) {
|
||||
/* eslint-disable no-invalid-this */
|
||||
message += `: found ${JSON.stringify(Array.from(found))}, wanted ${JSON.stringify(Array.from(wanted))}`;
|
||||
|
||||
// should always return another assert call, or
|
||||
// this.pass(message) or this.fail(message, extra)
|
||||
if (found.length !== wanted.length) {
|
||||
return this.fail(message, extra);
|
||||
}
|
||||
|
||||
for (let i = 0; i < found.length; i++) {
|
||||
// smallest meaningful difference--detects changes in hue value after rounding
|
||||
if (Math.abs(found[i] - wanted[i]) >= 0.5 / 360) {
|
||||
return this.fail(message, extra);
|
||||
}
|
||||
}
|
||||
|
||||
return this.pass(message);
|
||||
/* eslint-enable no-invalid-this */
|
||||
});
|
||||
|
||||
test('RGB to HSV', t => {
|
||||
const dst = [0, 0, 0];
|
||||
t.colorsAlmostEqual(rgbToHsv([255, 255, 255], dst), [0, 0, 1], 'white');
|
||||
t.colorsAlmostEqual(rgbToHsv([0, 0, 0], dst), [0, 0, 0], 'black');
|
||||
t.colorsAlmostEqual(rgbToHsv([127, 127, 127], dst), [0, 0, 0.498], 'grey');
|
||||
t.colorsAlmostEqual(rgbToHsv([255, 255, 0], dst), [0.167, 1, 1], 'yellow');
|
||||
t.colorsAlmostEqual(rgbToHsv([1, 0, 0], dst), [0, 1, 0.00392], 'dark red');
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HSV to RGB', t => {
|
||||
const dst = new Uint8ClampedArray(3);
|
||||
t.colorsAlmostEqual(hsvToRgb([0, 1, 1], dst), [255, 0, 0], 'red');
|
||||
t.colorsAlmostEqual(hsvToRgb([1, 1, 1], dst), [255, 0, 0], 'red (hue of 1)');
|
||||
t.colorsAlmostEqual(hsvToRgb([0.5, 1, 1], dst), [0, 255, 255], 'cyan');
|
||||
t.colorsAlmostEqual(hsvToRgb([1.5, 1, 1], dst), [0, 255, 255], 'cyan (hue of 1.5)');
|
||||
t.colorsAlmostEqual(hsvToRgb([0, 0, 0], dst), [0, 0, 0], 'black');
|
||||
t.colorsAlmostEqual(hsvToRgb([0.5, 1, 0], dst), [0, 0, 0], 'black (with hue and saturation)');
|
||||
t.colorsAlmostEqual(hsvToRgb([0, 1, 0.00392], dst), [1, 0, 0], 'dark red');
|
||||
|
||||
t.end();
|
||||
});
|
||||
148
scratch-render/test/unit/DrawableTests.js
Normal file
148
scratch-render/test/unit/DrawableTests.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const test = require('tap').test;
|
||||
|
||||
// Mock `window` and `document.createElement` for twgl.js.
|
||||
global.window = {};
|
||||
global.document = {
|
||||
createElement: () => ({getContext: () => {}})
|
||||
};
|
||||
|
||||
const Drawable = require('../../src/Drawable');
|
||||
const MockSkin = require('../fixtures/MockSkin');
|
||||
const Rectangle = require('../../src/Rectangle');
|
||||
|
||||
/**
|
||||
* Returns a Rectangle-like object, with dimensions rounded to the given number
|
||||
* of digits.
|
||||
* @param {Rectangle} rect The source rectangle.
|
||||
* @param {int} decimals The number of decimal points to snap to.
|
||||
* @returns {object} An object with left/right/top/bottom attributes.
|
||||
*/
|
||||
const snapToNearest = function (rect, decimals = 3) {
|
||||
return {
|
||||
left: rect.left.toFixed(decimals),
|
||||
right: rect.right.toFixed(decimals),
|
||||
bottom: rect.bottom.toFixed(decimals),
|
||||
top: rect.top.toFixed(decimals)
|
||||
};
|
||||
};
|
||||
|
||||
const mockRenderer = drawable => ({
|
||||
skinWasAltered: () => {
|
||||
drawable._skinWasAltered();
|
||||
}
|
||||
});
|
||||
|
||||
test('translate by position', t => {
|
||||
const expected = new Rectangle();
|
||||
const drawable = new Drawable(null, {});
|
||||
drawable.skin = new MockSkin(0, mockRenderer(drawable));
|
||||
drawable.skin.size = [200, 50];
|
||||
|
||||
expected.initFromBounds(0, 200, -50, 0);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
drawable.updateProperties({position: [1, 2]});
|
||||
expected.initFromBounds(1, 201, -48, 2);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('translate by costume center', t => {
|
||||
const expected = new Rectangle();
|
||||
const drawable = new Drawable(null, {});
|
||||
drawable.skin = new MockSkin(0, mockRenderer(drawable));
|
||||
drawable.skin.size = [200, 50];
|
||||
|
||||
drawable.skin.rotationCenter = [1, 0];
|
||||
expected.initFromBounds(-1, 199, -50, 0);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
drawable.skin.rotationCenter = [0, -2];
|
||||
expected.initFromBounds(0, 200, -52, -2);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('translate and rotate', t => {
|
||||
const expected = new Rectangle();
|
||||
const drawable = new Drawable(null, {});
|
||||
drawable.skin = new MockSkin(0, mockRenderer(drawable));
|
||||
drawable.skin.size = [200, 50];
|
||||
|
||||
drawable.updateProperties({position: [1, 2], direction: 0});
|
||||
expected.initFromBounds(1, 51, 2, 202);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
drawable.updateProperties({direction: 180});
|
||||
expected.initFromBounds(-49, 1, -198, 2);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
drawable.skin.rotationCenter = [100, 25];
|
||||
drawable.updateProperties({direction: 270, position: [0, 0]});
|
||||
expected.initFromBounds(-100, 100, -25, 25);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
drawable.updateProperties({direction: 90});
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('rotate by non-right-angles', t => {
|
||||
const expected = new Rectangle();
|
||||
const drawable = new Drawable(null, {});
|
||||
drawable.skin = new MockSkin(0, mockRenderer(drawable));
|
||||
drawable.skin.size = [10, 10];
|
||||
drawable.skin.rotationCenter = [5, 5];
|
||||
|
||||
expected.initFromBounds(-5, 5, -5, 5);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
drawable.updateProperties({direction: 45});
|
||||
expected.initFromBounds(-7.071, 7.071, -7.071, 7.071);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('scale', t => {
|
||||
const expected = new Rectangle();
|
||||
const drawable = new Drawable(null, {});
|
||||
drawable.skin = new MockSkin(0, mockRenderer(drawable));
|
||||
drawable.skin.size = [200, 50];
|
||||
|
||||
drawable.updateProperties({scale: [100, 50]});
|
||||
expected.initFromBounds(0, 200, -25, 0);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
drawable.skin.rotationCenter = [0, 25];
|
||||
expected.initFromBounds(0, 200, -12.5, 12.5);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
drawable.skin.rotationCenter = [150, 50];
|
||||
drawable.updateProperties({scale: [50, 50]});
|
||||
expected.initFromBounds(-75, 25, 0, 25);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('rotate and scale', t => {
|
||||
const expected = new Rectangle();
|
||||
const drawable = new Drawable(null, {});
|
||||
drawable.skin = new MockSkin(0, mockRenderer(drawable));
|
||||
drawable.skin.size = [100, 1000];
|
||||
|
||||
drawable.skin.rotationCenter = [50, 50];
|
||||
expected.initFromBounds(-50, 50, -950, 50);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
drawable.updateProperties({scale: [40, 60]});
|
||||
drawable.skin.rotationCenter = [50, 50];
|
||||
expected.initFromBounds(-20, 20, -570, 30);
|
||||
t.same(snapToNearest(drawable.getAABB()), expected);
|
||||
|
||||
t.end();
|
||||
});
|
||||
94
scratch-render/webpack.config.js
Normal file
94
scratch-render/webpack.config.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const path = require('path');
|
||||
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
|
||||
|
||||
const base = {
|
||||
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
||||
devServer: {
|
||||
contentBase: false,
|
||||
host: '0.0.0.0',
|
||||
port: process.env.PORT || 8361
|
||||
},
|
||||
devtool: 'cheap-module-source-map',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
include: [
|
||||
path.resolve('src')
|
||||
],
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: [['env', {targets: {browsers: ['last 3 versions', 'Safari >= 8', 'iOS >= 8']}}]]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new UglifyJsPlugin({
|
||||
include: /\.min\.js$/
|
||||
})
|
||||
]
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
module.exports = [
|
||||
// Playground
|
||||
Object.assign({}, base, {
|
||||
target: 'web',
|
||||
entry: {
|
||||
playground: './src/playground/playground.js',
|
||||
queryPlayground: './src/playground/queryPlayground.js'
|
||||
},
|
||||
output: {
|
||||
libraryTarget: 'umd',
|
||||
path: path.resolve('playground'),
|
||||
filename: '[name].js'
|
||||
},
|
||||
plugins: base.plugins.concat([
|
||||
new CopyWebpackPlugin([
|
||||
{
|
||||
context: 'src/playground',
|
||||
from: '*.+(html|css)'
|
||||
}
|
||||
])
|
||||
])
|
||||
}),
|
||||
// Web-compatible
|
||||
Object.assign({}, base, {
|
||||
target: 'web',
|
||||
entry: {
|
||||
'scratch-render': './src/index.js',
|
||||
'scratch-render.min': './src/index.js'
|
||||
},
|
||||
output: {
|
||||
library: 'ScratchRender',
|
||||
libraryTarget: 'umd',
|
||||
path: path.resolve('dist', 'web'),
|
||||
filename: '[name].js'
|
||||
}
|
||||
}),
|
||||
// Node-compatible
|
||||
Object.assign({}, base, {
|
||||
target: 'node',
|
||||
entry: {
|
||||
'scratch-render': './src/index.js'
|
||||
},
|
||||
output: {
|
||||
library: 'ScratchRender',
|
||||
libraryTarget: 'commonjs2',
|
||||
path: path.resolve('dist', 'node'),
|
||||
filename: '[name].js'
|
||||
},
|
||||
externals: {
|
||||
'!ify-loader!grapheme-breaker': 'grapheme-breaker',
|
||||
'!ify-loader!linebreak': 'linebreak',
|
||||
'hull.js': true,
|
||||
'@turbowarp/scratch-svg-renderer': true,
|
||||
'twgl.js': true,
|
||||
'xml-escape': true
|
||||
}
|
||||
})
|
||||
];
|
||||
Reference in New Issue
Block a user