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:
2026-06-16 15:37:45 +08:00
commit 6e0a1fbcbb
11350 changed files with 965674 additions and 0 deletions

View 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

View File

@@ -0,0 +1,4 @@
dist/*
node_modules/*
playground/*
tap-snapshots/*

View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ['scratch', 'scratch/node', 'scratch/es6']
};

34
scratch-render/.gitattributes vendored Normal file
View 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

View 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_

View 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_

View 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

View 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
View File

@@ -0,0 +1,20 @@
# Mac OS
.DS_Store
# NPM
/node_modules
npm-*
# Testing
/.nyc_output
/coverage
# IDEA
/.idea
# Build
/dist
/playground
# Act
.secrets

View 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
View 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
View File

@@ -0,0 +1 @@
v16

373
scratch-render/LICENSE Normal file
View 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
View 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
[![CircleCI](https://circleci.com/gh/LLK/scratch-render/tree/develop.svg?style=shield&circle-token=310da166a745295d515b3b90f3bad10f23b84405)](https://circleci.com/gh/LLK/scratch-render?branch=develop)
[![Greenkeeper badge](https://badges.greenkeeper.io/LLK/scratch-render.svg)](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
View 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.

View 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

File diff suppressed because it is too large Load Diff

View 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
}

View 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
}
]
}

View 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?
}
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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'
}
};

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View 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
View 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;

View 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;

View File

@@ -0,0 +1,7 @@
const RenderWebGL = require('./RenderWebGL');
/**
* Export for NPM & Node.js
* @type {RenderWebGL}
*/
module.exports = RenderWebGL;

View File

@@ -0,0 +1,9 @@
module.exports = {
extends: ['scratch'],
env: {
browser: true
},
rules: {
'no-console': 'off'
}
};

View 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;

View 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>

View 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);

View 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>

View 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();

View File

@@ -0,0 +1,11 @@
body {
background: lightsteelblue;
}
canvas {
border: 3px dashed black;
}
#debug-canvas {
border-color: red;
}

View 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;
}

View 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
}

View 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
}

View 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;

View 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};

View File

@@ -0,0 +1,4 @@
const nanolog = require('@turbowarp/nanolog');
nanolog.enable();
module.exports = nanolog('scratch-render');

View 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;

View 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;

View 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;
};

View 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>

View 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>

View 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);
});

View 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);
});

View 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);
});

View 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();
});

View 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();
});

View 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
}
})
];