squash commits

This commit is contained in:
MrPlatnum committed 2025-09-05 15:20:42 +02:00
commit 0f545c5e4d
611 files changed
+272565

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules/*
shared-assets
dist/*
lib/*
**/*.sw*
.DS_Store
.idea
*.iml
@@ -0,0 +1,93 @@
# Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of
experience, education, socio-economic status, nationality, personal appearance,
race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, or to ban temporarily or permanently any
contributor for other behaviors that they deem inappropriate, threatening,
offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
This Code of Conduct also applies outside the project spaces when the Project
Steward has a reasonable belief that an individual's behavior may have a
negative impact on the project or its community.
## Conflict Resolution
We do not believe that all conflict is bad; healthy debate and disagreement
often yield positive results. However, it is never okay to be disrespectful or
to engage in behavior that violates the project’s code of conduct.
If you see someone violating the code of conduct, you are encouraged to address
the behavior directly with those involved. Many issues can be resolved quickly
and easily, and this gives people more control over the outcome of their
dispute. If you are unable to resolve the matter for any reason, or if the
behavior is threatening or harassing, report it. We are dedicated to providing
an environment where participants feel welcome and safe.
Reports should be directed to Matt Small (mbsmall@google.com), the
Project Steward(s) for model-viewer. It is the Project Steward’s duty to
receive and address reported violations of the code of conduct. They will then
work with a committee consisting of representatives from the Open Source
Programs Office and the Google Open Source Strategy team. If for any reason you
are uncomfortable reaching out the Project Steward, please email
opensource@google.com.
We will investigate every complaint, but you may not receive a direct response.
We will use our discretion in determining when and how to follow up on reported
incidents, which may range from not taking action to permanent expulsion from
the project and project-sponsored spaces. We will notify the accused of the
report and provide them an opportunity to discuss it before any action is taken.
The identity of the reporter will be omitted from the details of the report
supplied to the accused. In potentially harmful situations, such as ongoing
harassment or threats to anyone's safety, we may take action without notice.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 1.4,
available at
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+99
View File
@@ -0,0 +1,99 @@
# `<model-viewer-effects>`
![npm bundle size](https://img.shields.io/bundlephobia/min/@google/model-viewer-effects)
![npm (scoped)](https://img.shields.io/npm/v/@google/model-viewer-effects)
`<model-viewer-effects>` is a web component library addon for `<model-viewer>` that makes adding post-processing
effects to your models easy to do, on as many browsers and devices as possible.
`<model-viewer-effects>` strives to give you great defaults for rendering quality and
performance.
![A 3D Model of a Rocket Ship](https://raw.githubusercontent.com/google/model-viewer/master/packages/model-viewer-effects/screenshot.png)
[Examples](https://modelviewer.dev/examples/postprocessing/) • [Documentation](https://modelviewer.dev/docs/mve)
## Usage
Using effects is as simple as adding the `<effect-composer>` inside your `<model-viewer>`, and placing any effects inside the composer component.
```html
<model-viewer src="...">
<effect-composer>
<bloom-effect></bloom-effect>
</effect-composer>
</model-viewer>
```
### PostProcessing
`<model-viewer-effects>` uses the [postprocessing](https://github.com/pmndrs/postprocessing) library under the hood, for its superior [performance](https://github.com/pmndrs/postprocessing#performance) and support.
In addition to the built-in effects wrapped by this library, you can add any custom effects/passes that follow the [postprocessing spec](https://github.com/pmndrs/postprocessing/wiki/Custom-Passes).
### *XR Support*
The effects are not supported in the `<model-viewer>` XR modes, which will render as usual.
## Installing
### NPM
The `<model-viewer-effects>` library can be installed from [NPM](https://npmjs.org):
```sh
npm install three @google/model-viewer @google/model-viewer-effects
```
### HTML
`<model-viewer-effects>` and `<model-viewer>` share a [Three.js](https://threejs.org/) dependency. In order to avoid version conflicts, you should bring Three through an `import-map`:
```html
<!-- ES-Shims for older browser compatibility -->
<script async src="https://ga.jspm.io/npm:[email protected]/dist/es-module-shims.js"></script>
<!-- Import Three.js using an import-map -->
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@^0.172.0/build/three.module.min.js"
}
}
</script>
```
You should then bring the `module` version of `<model-viewer>`, along with `<model-viewer-effects>` from your favourite CDN, such as [jsDelivr](https://www.jsdelivr.com/package/npm/@google/model-viewer):
```html
<script type="module" src=" https://cdn.jsdelivr.net/npm/@google/model-viewer/dist/model-viewer-module.min.js "></script>
<script type="module" src=" https://cdn.jsdelivr.net/npm/@google/model-viewer-effects/dist/model-viewer-effects.min.js "></script>
```
## Browser Support
`<model-viewer-effects>` is supported on the last 2 major versions of all evergreen
desktop and mobile browsers, and on all platforms (Android, IOS, MacOS, Windows, Linux).
| | <img src="https://github.com/alrra/browser-logos/raw/master/src/chrome/chrome_32x32.png" width="16"> Chrome | <img src="https://github.com/alrra/browser-logos/raw/master/src/firefox/firefox_32x32.png" width="16"> Firefox | <img src="https://github.com/alrra/browser-logos/raw/master/src/safari/safari_32x32.png" width="16"> Safari | <img src="https://github.com/alrra/browser-logos/raw/master/src/edge/edge_32x32.png" width="16"> Edge |
| -------- | --- | --- | --- | --- |
| Desktop | ✅ | ✅ | ✅ | ✅ |
| Mobile | ✅ | ✅ | ✅ | ✅ |
`<model-viewer-effects>` builds upon standard web platform APIs so that the performance,
capabilities and compatibility of the library get better as the web evolves.
## Development
To get started, follow the instructions in [the main README.md file](../../README.md).
The following commands are available when developing `<model-viewer-effects>`:
Command | Description
------------------------------- | -----------
`npm run build` | Builds all `<model-viewer-effects>` distributable files
`npm run build:dev` | Builds a subset of distributable files (faster than `npm run build`)
`npm run test` | Run `<model-viewer-effects>` unit tests
`npm run clean` | Deletes all build artifacts
`npm run dev` | Starts `tsc` and `rollup` in "watch" mode, causing artifacts to automatically rebuild upon incremental changes
----
*Rocket Ship by Daniel Melchior [CC-BY](https://creativecommons.org/licenses/by/3.0/) via [Poly Pizza](https://poly.pizza/m/9dyJn4gp7U8)*
+110
View File
@@ -0,0 +1,110 @@
{
"name": "@google/model-viewer-effects",
"type": "module",
"version": "1.4.0",
"description": "Easily add and combine post-processing effects with <model-viewer>!",
"repository": "https://github.com/google/model-viewer/tree/master/packages/model-viewer-effects",
"bugs": {
"url": "https://github.com/google/model-viewer/issues"
},
"homepage": "https://github.com/google/model-viewer/tree/master/packages/model-viewer-effects#readme",
"contributors": [
"Adam Beili <[email protected]>"
],
"license": "Apache-2.0",
"engines": {
"node": ">=6.0.0"
},
"main": "dist/model-viewer-effects.min.js",
"module": "lib/model-viewer-effects.js",
"files": [
"src",
"lib",
"dist/model-viewer-effects.js",
"dist/model-viewer-effects.js.map",
"dist/model-viewer-effects.min.js",
"dist/model-viewer-effects.min.js.map",
"dist/model-viewer-effects-umd.js",
"dist/model-viewer-effects-umd.js.map",
"dist/model-viewer-effects-umd.min.js",
"dist/model-viewer-effects-umd.min.js.map",
"dist/model-viewer-effects.d.ts"
],
"typings": "lib/model-viewer-effects.d.ts",
"types": "lib/model-viewer-effects.d.ts",
"scripts": {
"clean": "rm -rf ./lib ./dist",
"prepare": "if [ ! -L './shared-assets' ]; then ln -s ../shared-assets ./shared-assets; fi && ../shared-assets/scripts/fetch-khronos-gltf-samples.sh",
"build": "npm run build:tsc && npm run build:rollup",
"build:dev": "npm run build:tsc && npm run build:rollup:dev",
"build:tsc": "tsc --incremental",
"build:rollup": "rollup -c --environment NODE_ENV:production",
"build:rollup:dev": "rollup -c --environment NODE_ENV:development",
"prepublishOnly": "npm run build",
"test": "web-test-runner --playwright --browsers chromium firefox webkit",
"test:ci": "web-test-runner --static-logging --playwright --browsers chromium webkit",
"serve": "node_modules/.bin/http-server -c-1",
"dev": "npm run build:dev && npm-run-all --parallel 'watch:tsc -- --preserveWatchOutput' 'watch:test' 'serve -- -s'",
"watch:tsc": "tsc -w --incremental",
"watch:rollup": "rollup -c -w --environment NODE_ENV:production",
"watch:rollup:dev": "rollup -c -w --environment NODE_ENV:development",
"watch:test": "web-test-runner --node-resolve --playwright --browsers chromium --watch",
"build:dev:serve": "npm run build:dev && npm run serve"
},
"keywords": [
"ar",
"gltf",
"glb",
"webar",
"webvr",
"webxr",
"arcore",
"arkit",
"webaronarcore",
"webaronarkit",
"augmented reality",
"model-viewer",
"model-viewer-effects",
"3d",
"post",
"processing",
"effect",
"filter"
],
"dependencies": {
"lit": "^3.2.1",
"postprocessing": "^6.37.1"
},
"peerDependencies": {
"@google/model-viewer": "^4.1.0"
},
"devDependencies": {
"@google/model-viewer": "^4.1.0",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-replace": "^6.0.2",
"@types/mocha": "^10.0.10",
"@types/pngjs": "^6.0.5",
"@types/three": "^0.174.0",
"@ungap/event-target": "^0.2.4",
"@web/test-runner": "^0.20.0",
"@web/test-runner-playwright": "^0.11.0",
"chai": "^5.2.0",
"@rollup/plugin-swc": "^0.4.0",
"@swc/core": "^1.11.8",
"focus-visible": "^5.2.1",
"http-server": "^14.1.1",
"mocha": "^11.1.0",
"npm-run-all": "^4.1.5",
"rollup": "^4.35.0",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-dts": "^6.1.1",
"rollup-plugin-polyfill": "^4.2.0",
"@rollup/plugin-terser": "^0.4.4",
"three": "^0.174.0",
"typescript": "5.8.2"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,146 @@
/*
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import commonjs from '@rollup/plugin-commonjs';
import {nodeResolve as resolve} from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import swc from '@rollup/plugin-swc';
import terser from '@rollup/plugin-terser';
import cleanup from 'rollup-plugin-cleanup';
import dts from 'rollup-plugin-dts';
const {NODE_ENV} = process.env;
const onwarn = (warning, warn) => {
// Suppress non-actionable warning caused by TypeScript boilerplate:
if (warning.code !== 'THIS_IS_UNDEFINED') {
warn(warning);
}
};
let plugins = [
resolve(),
replace({'Reflect.decorate': 'undefined', preventAssignment: true})
];
const watchFiles = ['lib/**'];
const outputOptions = [
{
input: './lib/model-viewer-effects.js',
output: {
file: './dist/model-viewer-effects.js',
sourcemap: true,
format: 'esm',
name: 'ModelViewerEffects',
globals: {
three: 'three',
},
},
watch: {
include: watchFiles,
},
plugins,
external: ['three'],
onwarn,
},
];
if (NODE_ENV !== 'development') {
const pluginsIE11 = [
...plugins,
commonjs(),
swc(),
cleanup({
// Ideally we'd also clean third_party/three, which saves
// ~45kb in filesize alone... but takes 2 minutes to build
include: ['lib/**'],
comments: 'none',
}),
];
// IE11 does not support modules, so they are removed here, as well as in a
// dedicated unit test build which is needed for the same reason.
outputOptions.push({
input: './lib/model-viewer-effects.js',
output: {
file: './dist/model-viewer-effects-umd.js',
sourcemap: true,
format: 'umd',
name: 'ModelViewerEffects',
globals: {
three: 'three',
},
},
watch: {
include: watchFiles,
},
external: ['three'],
plugins: pluginsIE11,
onwarn,
});
plugins = [...plugins, terser()];
outputOptions.push({
input: './dist/model-viewer-effects.js',
output: {
file: './dist/model-viewer-effects.min.js',
sourcemap: true,
format: 'esm',
name: 'ModelViewerEffects',
globals: {
three: 'three',
},
},
watch: {
include: watchFiles,
},
external: ['three'],
plugins,
onwarn,
});
outputOptions.push({
input: './dist/model-viewer-effects-umd.js',
output: {
file: './dist/model-viewer-effects-umd.min.js',
sourcemap: true,
format: 'umd',
name: 'ModelViewerEffects',
globals: {
three: 'three',
},
},
watch: {
include: watchFiles,
},
external: ['three'],
plugins,
onwarn,
});
outputOptions.push({
input: './lib/model-viewer-effects.d.ts',
output: {
file: './dist/model-viewer-effects.d.ts',
format: 'esm',
name: 'ModelViewerEffects',
},
plugins: [dts()],
});
}
export default outputOptions;
Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

@@ -0,0 +1,419 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {ModelViewerElement} from '@google/model-viewer';
import {ModelScene} from '@google/model-viewer/lib/three-components/ModelScene.js';
import {ReactiveElement} from 'lit';
import {property} from 'lit/decorators.js';
import {EffectComposer as PPEffectComposer, EffectPass, NormalPass, Pass, RenderPass, Selection} from 'postprocessing';
import {Camera, HalfFloatType, NeutralToneMapping, ToneMapping, UnsignedByteType, WebGLRenderer} from 'three';
import {IMVEffect, IntegrationOptions, MVEffectBase} from './effects/mixins/effect-base.js';
import {disposeEffectPass, isConvolution, validateLiteralType} from './utilities.js';
export const $scene = Symbol('scene');
export const $composer = Symbol('composer');
export const $modelViewerElement = Symbol('modelViewerElement');
export const $effectComposer = Symbol('effectComposer');
export const $renderPass = Symbol('renderPass');
export const $normalPass = Symbol('normalPass');
export const $effectPasses = Symbol('effectsPass');
export const $requires = Symbol('requires');
export const $effects = Symbol('effects');
export const $selection = Symbol('selection');
export const $onSceneLoad = Symbol('onSceneLoad');
export const $resetEffectPasses = Symbol('resetEffectPasses');
export const $userEffectCount = Symbol('userEffectCount');
export const $tonemapping = Symbol('tonemapping');
const $updateProperties = Symbol('updateProperties');
/**
* Light wrapper around {@link EffectComposer} for storing the `scene` and
* `camera at a top level, and setting them for every {@link Pass} added.
*/
export class EffectComposer extends PPEffectComposer {
public camera?: Camera;
public scene?: ModelScene;
public dirtyRender?: boolean;
[$tonemapping]: ToneMapping = NeutralToneMapping;
constructor(renderer?: WebGLRenderer, options?: {
depthBuffer?: boolean;
stencilBuffer?: boolean;
alpha?: boolean;
multisampling?: number;
frameBufferType?: number;
}) {
super(renderer, options);
}
private preRender() {
// the EffectComposer expects autoClear to be false so that buffers aren't
// cleared between renders while the threeRenderer should be true so that
// the frames are cleared each render.
const renderer = this.getRenderer();
renderer.autoClear = false;
renderer.toneMapping = this[$tonemapping];
}
private postRender() {
const renderer = this.getRenderer();
renderer.toneMapping = NeutralToneMapping;
renderer.autoClear = true;
}
override render(deltaTime?: number|undefined): void {
this.preRender();
super.render(deltaTime);
this.postRender();
}
/**
* Adds a pass, optionally at a specific index.
* Additionally sets `scene` and `camera`.
* @param pass A new pass.
* @param index An index at which the pass should be inserted.
*/
override addPass(pass: Pass, index?: number): void {
super.addPass(pass, index);
this.refresh();
}
override setMainCamera(camera: Camera): void {
this.camera = camera;
super.setMainCamera(camera);
}
override setMainScene(scene: ModelScene): void {
this.scene = scene;
super.setMainScene(scene);
}
/**
* Effect Materials that use the camera need to be manually updated whenever
* the camera settings update.
*/
refresh(): void {
if (this.camera && this.scene) {
super.setMainCamera(this.camera);
super.setMainScene(this.scene);
}
}
beforeRender(_time: DOMHighResTimeStamp, _delta: DOMHighResTimeStamp): void {
if (this.dirtyRender) {
this.scene?.queueRender();
}
}
}
export type MVPass = Pass&IntegrationOptions;
export const RENDER_MODES = ['performance', 'quality'] as const;
export type RenderMode = typeof RENDER_MODES[number];
const N_DEFAULT_PASSES = 2; // RenderPass, NormalPass
export class MVEffectComposer extends ReactiveElement {
static get is() {
return 'effect-composer';
}
/**
* `quality` | `performance`. Changing this after the element was constructed
* has no effect.
*
* Using `quality` improves banding on certain effects, at a memory cost. Use
* in HDR scenarios.
*
* `performance` should be sufficient for most use-cases.
* @default 'performance'
*/
@property({type: String, attribute: 'render-mode'})
renderMode: RenderMode = 'performance';
/**
* Anti-Aliasing using the MSAA algorithm. Doesn't work well with depth-based
* effects.
*
* Recommended to use with a factor of 2.
* @default 0
*/
@property({type: Number, attribute: 'msaa'}) msaa: number = 0;
protected[$composer]?: EffectComposer;
protected[$modelViewerElement]?: ModelViewerElement;
protected[$renderPass]: RenderPass;
protected[$normalPass]: NormalPass;
protected[$selection]: Selection;
protected[$userEffectCount]: number = 0;
get[$effectComposer]() {
if (!this[$composer])
throw new Error(
'The EffectComposer has not been instantiated yet. Please make sure the component is properly mounted on the Document within a <model-viewer> element.');
return this[$composer];
}
/**
* Array of custom {@link MVPass}'s added with {@link addPass}.
*/
get userPasses(): MVPass[] {
return this[$effectComposer].passes.slice(
N_DEFAULT_PASSES, N_DEFAULT_PASSES + this[$userEffectCount]);
}
get modelViewerElement() {
if (!this[$modelViewerElement])
throw new Error(
'<effect-composer> must be a child of a <model-viewer> component.');
return this[$modelViewerElement];
}
/**
* The Texture buffer of the inbuilt {@link NormalPass}.
*/
get normalBuffer() {
return this[$normalPass].texture;
}
/**
* A selection of all {@link Mesh}'s in the ModelScene.
*/
get selection() {
return this[$selection];
}
/**
* Creates a new MVEffectComposer element.
*
* @warning The EffectComposer instance is created only on connection with the
* DOM, so that the renderMode is properly taken into account. Do not interact
* with this class if it is not mounted to the DOM.
*/
constructor() {
super();
this[$renderPass] = new RenderPass();
this[$normalPass] = new NormalPass();
this[$selection] = new Selection();
}
connectedCallback(): void {
super.connectedCallback && super.connectedCallback();
if (this.parentNode?.nodeName.toLowerCase() === 'model-viewer') {
this[$modelViewerElement] = this.parentNode as ModelViewerElement;
}
try {
validateLiteralType(RENDER_MODES, this.renderMode);
} catch (e) {
console.error(
(e as Error).message + '\nrenderMode defaulting to: performance');
}
this[$composer] = new EffectComposer(undefined, {
stencilBuffer: true,
multisampling: this.msaa,
frameBufferType: this.renderMode === 'quality' ? HalfFloatType :
UnsignedByteType,
});
this.modelViewerElement.registerEffectComposer(this[$effectComposer]);
this[$effectComposer].addPass(this[$renderPass], 0);
this[$effectComposer].addPass(this[$normalPass], 1);
this[$onSceneLoad]();
this.modelViewerElement.addEventListener(
'before-render', this[$onSceneLoad]);
this.updateEffects();
}
disconnectedCallback() {
super.disconnectedCallback && super.disconnectedCallback();
this.modelViewerElement.unregisterEffectComposer();
this.modelViewerElement.removeEventListener(
'before-render', this[$onSceneLoad]);
this[$effectComposer].dispose();
}
updated(changedProperties: Map<string|number|symbol, any>) {
super.updated(changedProperties);
if (changedProperties.has('msaa')) {
this[$effectComposer].multisampling = this.msaa;
}
if (changedProperties.has('renderMode') &&
changedProperties.get('renderMode') !== undefined) {
throw new Error('renderMode cannot be changed after startup.');
}
}
/**
* Adds a custom Pass that extends the {@link Pass} class.
* All passes added through this method will be prepended before all other
* web-component effects.
*
* This method automatically sets the `mainScene` and `mainCamera` of the
* pass.
* @param {Pass} pass Custom Pass to add. The camera and scene are set
* automatically.
* @param {boolean} requireNormals Whether any effect in this pass uses
* the {@link normalBuffer}
* @param {boolean} requireDirtyRender Enable this if the effect requires a
* render frame every frame. Significant performance impact from enabling
* this.
*/
addPass(pass: Pass, requireNormals?: boolean, requireDirtyRender?: boolean):
void {
(pass as MVPass).requireNormals = requireNormals;
(pass as MVPass).requireDirtyRender = requireDirtyRender;
this[$effectComposer].addPass(
pass,
this[$userEffectCount] +
N_DEFAULT_PASSES); // push after current userPasses, before any
// web-component effects.
this[$userEffectCount]++;
// Enable the normalPass and dirtyRendering if required by any effect.
this[$updateProperties]();
}
/**
* Removes and optionally disposes of a previously added Pass.
* @param pass Custom Pass to remove
* @param {Boolean} dispose Disposes of the Pass properties and effects.
* Default is `true`.
*/
removePass(pass: Pass, dispose: boolean = true): void {
if (!this[$effectComposer].passes.includes(pass))
throw new Error(`Pass ${pass.name} not found.`);
this[$effectComposer].removePass(pass);
if (dispose)
pass.dispose();
// Enable the normalPass and dirtyRendering if required by any effect.
this[$updateProperties]();
this[$userEffectCount]--;
}
/**
* Updates all existing EffectPasses, adding any new `<model-viewer-effects>`
* Effects in the order they were added, after any custom Passes added
* with {@link addPass}.
*
* Runs automatically whenever a new Effect is added.
*/
updateEffects(): void {
this[$resetEffectPasses]();
// Iterate over all effects (web-component), and combines as many as
// possible. Convolution effects must sit on their own EffectPass. In order
// to preserve the correct effect order, the convolution effects separate
// all effects before and after into separate EffectPasses.
const effects = this[$effects];
let i = 0;
while (i < effects.length) {
const separateIndex = effects.slice(i).findIndex(
(effect) => effect.requireSeparatePass || isConvolution(effect));
if (separateIndex != 0) {
const effectPass = new EffectPass(
undefined,
...effects.slice(
i, separateIndex == -1 ? effects.length : separateIndex));
this[$effectComposer].addPass(effectPass);
}
if (separateIndex != -1) {
const convolutionPass =
new EffectPass(undefined, effects[i + separateIndex]);
this[$effectComposer].addPass(convolutionPass);
i += separateIndex + 1;
} else {
break; // A convolution was not found, the first Effect pass contains
// all effects from i to effects.length
}
}
// Enable the normalPass and dirtyRendering if required by any effect.
this[$updateProperties]();
this.queueRender();
}
/**
* Request a render-frame manually.
*/
queueRender(): void {
this[$scene]?.queueRender();
}
get[$scene]() {
return this[$effectComposer].scene;
}
/**
* Gets child effects
*/
get[$effects](): IMVEffect[] {
// iterate over all web-component children effects
const effects: IMVEffect[] = [];
for (let i = 0; i < this.children.length; i++) {
const childEffect = this.children.item(i) as MVEffectBase;
if (!childEffect.effects)
continue;
const childEffects = childEffect.effects;
if (childEffects) {
effects.push(...childEffects.filter((effect) => !effect.disabled));
}
}
return effects;
}
/**
* Gets effectPasses of child effects
*/
get[$effectPasses]() {
return this[$effectComposer].passes.slice(
N_DEFAULT_PASSES + this[$userEffectCount]) as EffectPass[];
}
[$onSceneLoad] = (): void => {
this[$effectComposer].refresh();
// Place all Geometries in the selection
this[$selection].clear();
this[$scene]?.traverse(
(obj) => obj.hasOwnProperty('geometry') && this[$selection].add(obj));
this.dispatchEvent(new CustomEvent('updated-selection'));
};
[$updateProperties]() {
this[$normalPass].enabled = this[$requires]('requireNormals');
this[$normalPass].renderToScreen = false;
this[$effectComposer].dirtyRender = this[$requires]('requireDirtyRender');
this[$renderPass].renderToScreen =
this[$effectComposer].passes.length === N_DEFAULT_PASSES;
}
[$requires](property: 'requireNormals'|'requireSeparatePass'|
'requireDirtyRender'): boolean {
return this[$effectComposer].passes.some(
(pass: any) => pass[property] ||
(pass.effects &&
pass.effects.some((effect: IMVEffect) => effect[property])));
}
[$resetEffectPasses](): void {
this[$effectPasses].forEach((pass) => {
this[$effectComposer].removePass(pass);
disposeEffectPass(pass);
});
}
}
@@ -0,0 +1,90 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { property } from 'lit/decorators.js';
import { BlendFunction, BloomEffect } from 'postprocessing';
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
export class MVBloomEffect extends MVEffectBase {
static get is() {
return 'bloom-effect';
}
/**
* The strength of the bloom effect.
*/
@property({ type: Number, attribute: 'strength', reflect: true })
strength = 1;
/**
* Value in the range of (0, 1). Pixels with a brightness above this will bloom.
*/
@property({ type: Number, attribute: 'threshold', reflect: true })
threshold = 0.85;
/**
* Value in the range of (0, 1).
*/
@property({ type: Number, attribute: 'radius', reflect: true })
radius = 0.85;
/**
* Value in the range of (0, 1).
*/
@property({ type: Number, attribute: 'smoothing', reflect: true })
smoothing = 0.025;
constructor() {
super();
this.effects = [new BloomEffect(this[$effectOptions])];
}
connectedCallback(): void {
super.connectedCallback && super.connectedCallback();
this[$updateProperties]();
}
updated(changedProperties: Map<string | number | symbol, any>) {
super.updated(changedProperties);
if (
changedProperties.has('strength') ||
changedProperties.has('threshold') ||
changedProperties.has('smoothing') ||
changedProperties.has('radius')
) {
this[$updateProperties]();
}
}
[$updateProperties](): void {
(this.effects[0] as BloomEffect).luminanceMaterial.threshold = this.threshold;
(this.effects[0] as BloomEffect).luminanceMaterial.smoothing = this.smoothing;
(this.effects[0] as BloomEffect).intensity = this.strength;
(this.effects[0] as any).mipmapBlurPass.radius = this.radius;
this.effectComposer.queueRender();
}
get [$effectOptions]() {
return {
blendFunction: BlendFunction.ADD,
mipmapBlur: true,
radius: this.radius,
luminanceThreshold: this.threshold,
luminanceSmoothing: this.smoothing,
intensity: this.strength,
} as ConstructorParameters<typeof BloomEffect>[0];
}
}
@@ -0,0 +1,128 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {property} from 'lit/decorators.js';
import {BlendFunction, BrightnessContrastEffect, HueSaturationEffect, ToneMappingEffect, ToneMappingMode as PPToneMappingMode} from 'postprocessing';
import {NeutralToneMapping, NoToneMapping} from 'three';
import {$effectComposer, $tonemapping} from '../effect-composer.js';
import {clamp, validateLiteralType, wrapClamp} from '../utilities.js';
import {$updateProperties, MVEffectBase} from './mixins/effect-base.js';
const TWO_PI = Math.PI * 2;
export type ToneMappingMode = keyof typeof PPToneMappingMode;
;
export const TONEMAPPING_MODES =
Object.keys(PPToneMappingMode) as ToneMappingMode[];
export class MVColorGradeEffect extends MVEffectBase {
static get is() {
return 'color-grade-effect';
}
/**
* `reinhard | reinhard2 | reinhard_adaptive | optimized_cineon | aces_filmic
* | linear`
* @default 'aces_filmic'
*/
@property({type: String, attribute: 'tonemapping', reflect: true})
tonemapping: ToneMappingMode = 'ACES_FILMIC'
/**
* Value in the range of (-1, 1).
*/
@property({type: Number, attribute: 'brightness', reflect: true})
brightness = 0;
/**
* Value in the range of (-1, 1).
*/
@property({type: Number, attribute: 'contrast', reflect: true}) contrast = 0;
/**
* Value in the range of (-1, 1).
*/
@property({type: Number, attribute: 'saturation', reflect: true})
saturation = 0;
/**
* Value in the range of (0, 2 * PI).
*
* This property is wrapping, meaning that if you set it above the max it
* resets to the minimum and vice-versa.
*/
@property({type: Number, attribute: 'hue', reflect: true}) hue = 0;
constructor() {
super();
this.effects = [
new ToneMappingEffect({
mode: PPToneMappingMode.ACES_FILMIC,
}),
new HueSaturationEffect({
hue: wrapClamp(this.hue, 0, TWO_PI),
saturation: clamp(this.saturation, -1, 1),
blendFunction: BlendFunction.SRC,
}),
new BrightnessContrastEffect({
brightness: clamp(this.brightness, -1, 1),
contrast: clamp(this.contrast, -1, 1),
blendFunction: BlendFunction.SRC,
}),
];
}
connectedCallback(): void {
super.connectedCallback && super.connectedCallback();
this[$updateProperties]();
}
updated(changedProperties: Map<string|number|symbol, any>) {
super.updated(changedProperties);
if (changedProperties.has('tonemapping') ||
changedProperties.has('brightness') ||
changedProperties.has('contrast') || changedProperties.has('hue') ||
changedProperties.has('saturation') ||
changedProperties.has('blendMode')) {
this[$updateProperties]();
}
}
[$updateProperties]() {
if (this.blendMode === 'SKIP') {
this.effectComposer[$effectComposer][$tonemapping] = NeutralToneMapping;
} else {
this.effectComposer[$effectComposer][$tonemapping] = NoToneMapping;
}
this.saturation = clamp(this.saturation, -1, 1);
this.hue = wrapClamp(this.hue, 0, TWO_PI);
this.brightness = clamp(this.brightness, -1, 1);
this.contrast = clamp(this.contrast, -1, 1);
(this.effects[1] as HueSaturationEffect).saturation = this.saturation;
(this.effects[1] as HueSaturationEffect).hue = this.hue;
(this.effects[2] as BrightnessContrastEffect).brightness = this.brightness;
(this.effects[2] as BrightnessContrastEffect).contrast = this.contrast;
try {
this.tonemapping = this.tonemapping.toUpperCase() as ToneMappingMode;
validateLiteralType(TONEMAPPING_MODES, this.tonemapping);
(this.effects[0] as ToneMappingEffect).mode =
PPToneMappingMode[this.tonemapping];
} finally {
this.effectComposer.queueRender();
}
}
}
@@ -0,0 +1,93 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { property } from 'lit/decorators.js';
import { ChromaticAberrationEffect, GlitchEffect, GlitchMode as Mode } from 'postprocessing';
import { Vector2 } from 'three';
import { clamp, validateLiteralType } from '../utilities.js';
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
export const GLITCH_MODES = ['sporadic', 'constant'] as const;
export type GlitchMode = typeof GLITCH_MODES[number];
export class MVGlitchEffect extends MVEffectBase {
static get is() {
return 'glitch-effect';
}
/**
* Value in the range of (0, 1).
*/
@property({ type: Number, attribute: 'strength', reflect: true })
strength: number = 0.5;
/**
* `sporadic` | `constant`
* @default 'sporadic'
*/
@property({ type: String, attribute: 'mode', reflect: true })
mode: GlitchMode = 'sporadic';
constructor() {
super();
const chromaticAberrationEffect = new ChromaticAberrationEffect();
const glitchEffect = new GlitchEffect(this[$effectOptions](chromaticAberrationEffect));
this.effects = [glitchEffect, chromaticAberrationEffect];
this.effects[1].requireDirtyRender = true;
}
connectedCallback() {
super.connectedCallback && super.connectedCallback();
this[$updateProperties]();
}
updated(changedProperties: Map<string | number | symbol, any>) {
super.updated(changedProperties);
if (changedProperties.has('mode') || changedProperties.has('strength')) {
this[$updateProperties]();
}
}
[$updateProperties](): void {
this.strength = clamp(this.strength, 0, 1);
this.mode = this.mode.toLowerCase() as GlitchMode;
try {
validateLiteralType(GLITCH_MODES, this.mode);
} catch(e) {
console.error((e as Error).message + + "\nmode defaulting to 'sporadic'")
}
if (this.strength == 0) {
(this.effects[0] as GlitchEffect).columns = 0;
(this.effects[0] as GlitchEffect).mode = this.mode === 'constant' ? Mode.CONSTANT_MILD : Mode.SPORADIC;
} else {
(this.effects[0] as GlitchEffect).columns = 0.06;
(this.effects[0] as GlitchEffect).mode = this.mode === 'constant' ? Mode.CONSTANT_WILD : Mode.SPORADIC;
}
(this.effects[0] as GlitchEffect).maxStrength = this.strength;
(this.effects[0] as GlitchEffect).ratio = 1 - this.strength;
}
[$effectOptions](chromaticAberrationEffect: ChromaticAberrationEffect) {
this.strength = clamp(this.strength, 0, 1);
return {
chromaticAberrationOffset: chromaticAberrationEffect.offset,
delay: new Vector2(1 * 1000, 3.5 * 1000),
duration: new Vector2(0.5 * 1000, 1 * 1000),
strength: new Vector2(0.075, this.strength),
ratio: 1 - this.strength,
} as ConstructorParameters<typeof GlitchEffect>[0];
}
}
@@ -0,0 +1,85 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ReactiveElement } from 'lit';
import { property } from 'lit/decorators.js';
import { BlendFunction } from 'postprocessing';
import { Constructor, clampNormal, validateLiteralType } from '../../utilities.js';
import { IEffectBaseMixin } from './effect-base.js';
export const $setDefaultProperties = Symbol('setDefaultProperties');
export type BlendMode = keyof typeof BlendFunction;
export const BLEND_MODES = Object.keys(BlendFunction) as BlendMode[];
export interface IBlendModeMixin {
opacity: number;
blendMode: BlendMode;
[$setDefaultProperties](): void;
}
export const BlendModeMixin = <T extends Constructor<IEffectBaseMixin & ReactiveElement>>(
EffectClass: T
): Constructor<IBlendModeMixin> & T => {
class BlendEffectElement extends EffectClass {
/**
* The function to use to blend the effect with the base render.
*/
@property({ type: String, attribute: 'blend-mode', reflect: true })
blendMode: 'DEFAULT' | BlendMode = 'DEFAULT';
/**
* The opacity of the effect that will be blended with the base render.
*/
@property({ type: Number, attribute: 'opacity', reflect: true })
opacity: number = 1;
connectedCallback() {
super.connectedCallback && super.connectedCallback();
this[$setDefaultProperties]();
}
updated(changedProperties: Map<string | number | symbol, any>) {
super.updated(changedProperties);
if (changedProperties.has('blendMode') || changedProperties.has('opacity')) {
this.opacity = clampNormal(this.opacity);
this.blendMode = this.blendMode.toUpperCase() as BlendMode;
this.effects.forEach((effect) => {
if (this.blendMode === 'DEFAULT') {
if (effect.blendMode.defaultBlendFunction === undefined) throw new Error(`${effect.name} has no default blend function`);
effect.blendMode.blendFunction = effect.blendMode.defaultBlendFunction;
} else {
validateLiteralType(BLEND_MODES, this.blendMode);
effect.blendMode.blendFunction = BlendFunction[this.blendMode];
}
effect.disabled = this.blendMode === 'SKIP';
effect.blendMode.setOpacity(this.opacity);
});
// Recreate EffectPasses if the new or old value was 'skip'
if (this.blendMode === 'SKIP' || changedProperties.get('blendMode') === 'SKIP') {
this.effectComposer.updateEffects();
}
this.effectComposer.queueRender();
}
}
protected [$setDefaultProperties]() {
this.effects.forEach((effect) => {
effect.blendMode.defaultBlendFunction = effect.blendMode.blendFunction;
});
}
}
return BlendEffectElement as Constructor<IBlendModeMixin> & T;
};
@@ -0,0 +1,87 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LitElement, ReactiveElement } from 'lit';
import { BlendFunction, BlendMode, Effect } from 'postprocessing';
import { $effectComposer, MVEffectComposer } from '../../effect-composer.js';
import { Constructor } from '../../utilities.js';
import { BlendModeMixin } from './blend-mode.js';
import { getComponentName } from '../utilities.js';
export const $updateProperties = Symbol('updateProperties');
export const $effectOptions = Symbol('effectOptions');
export interface IMVBlendMode extends BlendMode {
defaultBlendFunction?: BlendFunction;
}
export interface IntegrationOptions {
/**
* Enable this if effect uses the built-in {@link NormalPass}
*/
requireNormals?: boolean;
/**
* Enable this if the effect requires a render frame every frame.
* @warning Significant performance impact from enabling this
*/
requireDirtyRender?: boolean;
}
export interface IMVEffect extends Effect, IntegrationOptions {
readonly blendMode: IMVBlendMode;
/**
* Enable this if the effect doesn't play well when used with other effects.
*/
requireSeparatePass?: boolean;
disabled?: boolean;
}
export interface IEffectBaseMixin {
effects: IMVEffect[];
effectComposer: MVEffectComposer;
}
export const EffectBaseMixin = <T extends Constructor<ReactiveElement>>(EffectClass: T): Constructor<IEffectBaseMixin> & T => {
class EffectBaseElement extends EffectClass {
[$effectComposer]?: MVEffectComposer;
protected effects!: IMVEffect[];
/**
* The parent {@link MVEffectComposer} element.
*/
protected get effectComposer() {
if (!this[$effectComposer]) throw new Error(`${getComponentName(this as any)} must be a child of a <model-viewer> component.`);
return this[$effectComposer];
}
connectedCallback(): void {
super.connectedCallback && super.connectedCallback();
if (this.parentNode?.nodeName.toLowerCase() === 'effect-composer') {
this[$effectComposer] = this.parentNode as MVEffectComposer;
}
this.effectComposer.updateEffects();
}
disconnectedCallback() {
super.disconnectedCallback && super.disconnectedCallback();
this.effects.forEach((effect) => effect.dispose());
this.effectComposer.updateEffects();
}
}
return EffectBaseElement as Constructor<IEffectBaseMixin> & T;
};
export const MVEffectBase = BlendModeMixin(EffectBaseMixin(LitElement));
export type MVEffectBase = InstanceType<typeof MVEffectBase>;
@@ -0,0 +1,80 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ReactiveElement } from 'lit';
import { property } from 'lit/decorators.js';
import { Selection } from 'postprocessing';
import { $selection, $scene } from '../../effect-composer.js';
import { Constructor } from '../../utilities.js';
import { IEffectBaseMixin, IMVEffect } from './effect-base.js';
import { Object3D } from 'three';
export const $setSelection = Symbol('setSelection');
export interface ISelectionEffect extends IMVEffect {
selection?: Selection;
}
export interface ISelectiveMixin {
selection: Array<string | Object3D>;
}
export const SelectiveMixin = <T extends Constructor<IEffectBaseMixin & ReactiveElement>>(
EffectClass: T
): Constructor<ISelectiveMixin> & T => {
class SelectiveEffectElement extends EffectClass {
/**
* The objects to attemp to place into the effect selection. Can be either the 'name' or the actual objects themselves.
*
* Note that since this is an array property, it must be set using the '=' operator in order to properly update.
*/
@property({ type: Array })
selection: Array<string | Object3D> = [];
connectedCallback() {
super.connectedCallback && super.connectedCallback();
this[$setSelection]();
this.effectComposer.addEventListener('updated-selection', this[$setSelection]);
}
disconnectedCallback(): void {
super.disconnectedCallback && super.disconnectedCallback();
this.effectComposer.removeEventListener('updated-selection', this[$setSelection]);
}
updated(changedProperties: Map<string | number | symbol, any>) {
super.updated(changedProperties);
if (changedProperties.has('selection')) {
this[$setSelection]();
this.effectComposer.queueRender();
}
}
[$setSelection] = () => {
const { effectComposer } = this;
if (!effectComposer) return;
if (this.selection?.length > 0) {
const selection: Object3D[] = [];
const scene = effectComposer[$scene];
scene?.traverse((obj) => (this.selection.includes(obj.name) || this.selection.includes(obj)) && selection.push(obj));
this.effects.forEach((effect: ISelectionEffect) => effect.selection?.set(selection));
} else {
this.effects.forEach((effect: ISelectionEffect) => effect.selection?.set(effectComposer[$selection].values()));
}
};
}
return SelectiveEffectElement as Constructor<ISelectiveMixin> & T;
};
@@ -0,0 +1,88 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { property } from 'lit/decorators.js';
import { BlendFunction, OutlineEffect } from 'postprocessing';
import { Color, ColorRepresentation } from 'three';
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
import { SelectiveMixin } from './mixins/selective.js';
import { getKernelSize, TEMP_CAMERA } from './utilities.js';
export class MVOutlineEffect extends SelectiveMixin(MVEffectBase) {
static get is() {
return 'outline-effect';
}
/**
* String or RGB #-hexadecimal Color.
* @default 'white'
*/
@property({ type: String || Number, attribute: 'color', reflect: true })
color: ColorRepresentation = 'white';
/**
* A larger value denotes a thicker edge.
* @default 1
*/
@property({ type: Number, attribute: 'strength', reflect: true })
strength = 1;
/**
* Value in the range of (0, 6). Controls the edge blur strength.
* @default 1
*/
@property({ type: Number, attribute: 'smoothing', reflect: true })
smoothing = 1;
constructor() {
super();
this.effects = [new OutlineEffect(undefined, TEMP_CAMERA, this[$effectOptions])];
}
connectedCallback(): void {
super.connectedCallback && super.connectedCallback();
this[$updateProperties]();
}
updated(changedProperties: Map<string | number | symbol, any>) {
super.updated(changedProperties);
if (changedProperties.has('color') || changedProperties.has('smoothing') || changedProperties.has('strength')) {
this[$updateProperties]();
}
}
[$updateProperties]() {
(this.effects[0] as OutlineEffect).edgeStrength = this.strength;
(this.effects[0] as OutlineEffect).visibleEdgeColor = new Color(this.color);
(this.effects[0] as OutlineEffect).hiddenEdgeColor = new Color(this.color);
(this.effects[0] as OutlineEffect).blurPass.enabled = Math.round(this.smoothing) > 0;
(this.effects[0] as OutlineEffect).blurPass.kernelSize = getKernelSize(this.smoothing);
this.effectComposer.queueRender();
}
get [$effectOptions]() {
return {
blendFunction: BlendFunction.SCREEN,
edgeStrength: this.strength,
pulseSpeed: 0.0,
visibleEdgeColor: new Color(this.color).getHex(),
hiddenEdgeColor: new Color(this.color).getHex(),
blur: Math.round(this.smoothing) > 0,
kernelSize: getKernelSize(this.smoothing),
xRay: true,
resolutionScale: 1,
} as ConstructorParameters<typeof OutlineEffect>[2];
}
}
@@ -0,0 +1,54 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { property } from 'lit/decorators.js';
import { PixelationEffect } from 'postprocessing';
import { $updateProperties, MVEffectBase } from './mixins/effect-base.js';
export class MVPixelateEffect extends MVEffectBase {
static get is() {
return 'pixelate-effect';
}
/**
* The pixel granularity. Higher value = lower resolution.
* @default 10
*/
@property({ type: Number, attribute: 'granularity', reflect: true })
granularity = 10.0;
constructor() {
super();
this.effects = [new PixelationEffect(this.granularity)];
}
connectedCallback(): void {
super.connectedCallback && super.connectedCallback();
this[$updateProperties]();
}
updated(changedProperties: Map<string | number | symbol, any>) {
super.updated(changedProperties);
if (changedProperties.has('granularity')) {
this[$updateProperties]();
}
}
[$updateProperties]() {
(this.effects[0] as PixelationEffect).granularity = this.granularity;
this.effectComposer.queueRender();
}
}
@@ -0,0 +1,92 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { property } from 'lit/decorators.js';
import { BlendFunction, SelectiveBloomEffect } from 'postprocessing';
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
import { SelectiveMixin } from './mixins/selective.js';
import { TEMP_CAMERA } from './utilities.js';
export class MVSelectiveBloomEffect extends SelectiveMixin(MVEffectBase) {
static get is() {
return 'selective-bloom-effect';
}
/**
* The strength of the bloom effect.
*/
@property({ type: Number, attribute: 'strength', reflect: true })
strength = 1;
/**
* Value in the range of (0, 1). Pixels with a brightness above this will bloom.
*/
@property({ type: Number, attribute: 'threshold', reflect: true })
threshold = 0.85;
/**
* Value in the range of (0, 1).
*/
@property({ type: Number, attribute: 'smoothing', reflect: true })
smoothing = 0.025;
/**
* Value in the range of (0, 1).
*/
@property({ type: Number, attribute: 'radius', reflect: true })
radius = 0.85;
constructor() {
super();
this.effects = [new SelectiveBloomEffect(undefined, TEMP_CAMERA, this[$effectOptions])];
}
connectedCallback(): void {
super.connectedCallback && super.connectedCallback();
this[$updateProperties]();
}
updated(changedProperties: Map<string | number | symbol, any>) {
super.updated(changedProperties);
if (
changedProperties.has('strength') ||
changedProperties.has('threshold') ||
changedProperties.has('smoothing') ||
changedProperties.has('radius')
) {
this[$updateProperties]();
}
}
[$updateProperties](): void {
(this.effects[0] as SelectiveBloomEffect).luminanceMaterial.threshold = this.threshold;
(this.effects[0] as SelectiveBloomEffect).luminanceMaterial.smoothing = this.smoothing;
(this.effects[0] as SelectiveBloomEffect).intensity = this.strength;
(this.effects[0] as any).mipmapBlurPass.radius = this.radius;
this.effectComposer.queueRender();
}
get [$effectOptions]() {
return {
blendFunction: BlendFunction.ADD,
mipmapBlur: true,
radius: this.radius,
luminanceThreshold: this.threshold,
luminanceSmoothing: this.smoothing,
intensity: this.strength,
} as ConstructorParameters<typeof SelectiveBloomEffect>[2];
}
}
@@ -0,0 +1,60 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { property } from 'lit/decorators.js';
import { SMAAEffect, SMAAPreset } from 'postprocessing';
import { $updateProperties, MVEffectBase } from './mixins/effect-base.js';
import { validateLiteralType } from '../utilities.js';
export type SMAAQuality = keyof typeof SMAAPreset;
export const SMAA_QUALITIES = Object.keys(SMAAPreset) as SMAAQuality[];
export class MVSMAAEffect extends MVEffectBase {
static get is() {
return 'smaa-effect';
}
/**
* `low | medium | high | ultra`
* @default 'medium'
*/
@property({ type: String, attribute: 'quality', reflect: true })
quality: SMAAQuality = 'MEDIUM';
constructor() {
super();
this.effects = [new SMAAEffect({ preset: SMAAPreset[this.quality] })];
}
connectedCallback(): void {
super.connectedCallback && super.connectedCallback();
this[$updateProperties]();
}
updated(changedProperties: Map<string | number | symbol, any>) {
super.updated(changedProperties);
if (changedProperties.has('quality')) {
this[$updateProperties]();
}
}
[$updateProperties]() {
this.quality = this.quality.toUpperCase() as SMAAQuality;
validateLiteralType(SMAA_QUALITIES, this.quality);
(this.effects[0] as SMAAEffect).applyPreset(SMAAPreset[this.quality]);
this.effectComposer.queueRender();
}
}
@@ -0,0 +1,75 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SSAOEffect } from 'postprocessing';
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
import { property } from 'lit/decorators.js';
import { TEMP_CAMERA } from './utilities.js';
import { $setDefaultProperties } from './mixins/blend-mode.js';
export class MVSSAOEffect extends MVEffectBase {
static get is() {
return 'ssao-effect';
}
/**
* The strength of the shadow occlusions. Higher value means darker shadows.
*/
@property({ type: Number, attribute: 'strength', reflect: true })
strength: number = 2;
constructor() {
super();
this.effects = [new SSAOEffect(TEMP_CAMERA, undefined, this[$effectOptions])];
this.effects[0].requireNormals = true;
}
connectedCallback(): void {
super.connectedCallback && super.connectedCallback();
this[$setDefaultProperties]();
this[$updateProperties]();
}
update(changedProperties: Map<string | number | symbol, any>): void {
super.update && super.update(changedProperties);
if (changedProperties.has('strength')) {
this[$updateProperties]();
}
}
[$updateProperties](): void {
(this.effects[0] as SSAOEffect).intensity = this.strength;
this.effectComposer.queueRender();
}
[$setDefaultProperties]() {
super[$setDefaultProperties]();
(this.effects[0] as SSAOEffect).normalBuffer = this.effectComposer.normalBuffer;
}
get [$effectOptions]() {
return {
worldDistanceThreshold: 1000,
worldDistanceFalloff: 1000,
worldProximityThreshold: 1000,
worldProximityFalloff: 1000,
luminanceInfluence: 0.7,
samples: 16,
fade: 0.05,
radius: 0.05,
intensity: this.strength,
} as ConstructorParameters<typeof SSAOEffect>[2];
}
}
@@ -0,0 +1,35 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KernelSize } from 'postprocessing';
import { PerspectiveCamera } from 'three';
import { clamp } from '../utilities.js';
import { MVEffectBase } from './mixins/effect-base.js';
/**
* Helper function for calculating the Kernel Size
* @param n Range(0, 6)
* @returns The relative Kernel Size
*/
export function getKernelSize(n: number): number {
return Math.round(clamp(n + 1, KernelSize.VERY_SMALL, KernelSize.HUGE + 1)) - 1;
}
export function getComponentName(obj: MVEffectBase): string {
return '<' + obj.constructor.name.replace('MV', '').split(/(?=[A-Z])/).join('-').toLowerCase() + '>';
}
// Used for effects which require a valid Camera for shader instance
export const TEMP_CAMERA = new PerspectiveCamera();
@@ -0,0 +1,64 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MVBloomEffect } from './effects/bloom.js';
import { MVColorGradeEffect } from './effects/color-grade.js';
import { MVGlitchEffect } from './effects/glitch.js';
import { MVOutlineEffect } from './effects/outline.js';
import { MVPixelateEffect } from './effects/pixelate.js';
import { MVSMAAEffect } from './effects/smaa.js';
import { MVSSAOEffect } from './effects/ssao.js';
import { MVEffectComposer } from './effect-composer.js';
import { MVEffectBase } from './effects/mixins/effect-base.js';
import { SelectiveMixin } from './effects/mixins/selective.js';
import { MVSelectiveBloomEffect } from './effects/selective-bloom.js';
customElements.define('effect-composer', MVEffectComposer);
customElements.define('pixelate-effect', MVPixelateEffect);
customElements.define('bloom-effect', MVBloomEffect);
customElements.define('selective-bloom-effect', MVSelectiveBloomEffect);
customElements.define('color-grade-effect', MVColorGradeEffect);
customElements.define('outline-effect', MVOutlineEffect);
customElements.define('smaa-effect', MVSMAAEffect);
customElements.define('ssao-effect', MVSSAOEffect);
customElements.define('glitch-effect', MVGlitchEffect);
declare global {
interface HTMLElementTagNameMap {
'effect-composer': MVEffectComposer;
'pixelate-effect': MVPixelateEffect;
'bloom-effect': MVBloomEffect;
'selective-bloom-effect': MVSelectiveBloomEffect;
'color-grade-effect': MVColorGradeEffect;
'outline-effect': MVOutlineEffect;
'ssao-effect': MVSSAOEffect;
'smaa-effect': MVSMAAEffect;
'glitch-effect': MVGlitchEffect;
}
}
export {
MVEffectComposer as EffectComposer,
MVPixelateEffect as PixelateEffect,
MVBloomEffect as BloomEffect,
MVSelectiveBloomEffect as SelectiveBloomEffect,
MVColorGradeEffect as ColorGradeEffect,
MVOutlineEffect as OutlineEffect,
MVSSAOEffect as SSAOEffect,
MVSMAAEffect as SMAAEffect,
MVGlitchEffect as GlitchEffect,
MVEffectBase as EffectBase,
SelectiveMixin,
};
@@ -0,0 +1,102 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {ModelViewerElement} from '@google/model-viewer';
import {expect} from 'chai';
import {DotScreenEffect, Effect, EffectPass, GridEffect} from 'postprocessing';
import {Camera} from 'three';
import {$effectComposer, $normalPass, $renderPass, $scene} from '../effect-composer.js';
import {EffectComposer} from '../model-viewer-effects.js';
import {assetPath, createModelViewerElement, waitForEvent} from './utilities.js';
suite('MVEffectComposer', () => {
let element: ModelViewerElement;
let composer: EffectComposer;
setup(async () => {
element = createModelViewerElement(assetPath('models/Astronaut.glb'));
composer = new EffectComposer();
element.insertBefore(composer, element.firstChild);
await waitForEvent(element, 'before-render');
});
teardown(() => {
document.body.removeChild(element);
});
suite('registered successfully', () => {
suite('scene+camera', () => {
test('has scene', () => {
expect(composer[$scene]).to.be.ok;
});
test('has camera', () => {
expect(composer[$scene]).to.be.ok;
});
});
suite('passes, selection', () => {
test('renderPass + normalPass added successfuly', () => {
expect(composer[$renderPass]).to.be.ok;
expect(composer[$normalPass]).to.be.ok;
expect(composer[$effectComposer].passes.length).to.eq(2);
expect(composer[$effectComposer].passes[0])
.to.eq(composer[$renderPass]);
expect(composer[$effectComposer].passes[1])
.to.eq(composer[$normalPass]);
expect(composer[$normalPass].enabled).to.be.false;
expect(composer[$normalPass].renderToScreen).to.be.false;
expect((composer[$renderPass] as any).scene).to.eq(composer[$scene]);
});
test('selection finds Meshes', () => {
expect(composer.selection.size).to.be.greaterThan(0);
});
});
});
suite('userEffects', () => {
let pass: EffectPass;
let effects: Effect[] = [];
test('adds grid effect', () => {
const effect = new GridEffect();
effects.push(effect);
pass = new EffectPass(composer[$scene]?.camera as Camera, ...effects);
composer.addPass(pass);
expect(composer[$effectComposer].passes.length).to.eq(3);
expect(composer[$effectComposer].passes[2]).to.eq(pass);
expect((composer[$effectComposer].passes[2] as any).effects)
.to.contain(effect);
composer.removePass(pass, false);
});
test('multiple effects all on one layer', async () => {
const effect = new DotScreenEffect();
effects.push(effect);
pass = new EffectPass(composer[$scene]?.camera as Camera, ...effects);
composer.addPass(pass);
expect(composer[$effectComposer].passes.length).to.eq(3);
expect(composer[$effectComposer].passes[2]).to.eq(pass);
expect((composer[$effectComposer].passes[2] as any).effects.length)
.to.eq(2);
expect((composer[$effectComposer].passes[2] as any).effects)
.to.contain(effect);
composer.removePass(pass, false);
});
});
});
@@ -0,0 +1,86 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {ModelViewerElement} from '@google/model-viewer';
import {expect} from 'chai';
import {ColorGradeEffect, EffectComposer} from '../../model-viewer-effects.js';
import {ArraysAreEqual, assetPath, AverageHSL, CompareArrays, createModelViewerElement, rafPasses, screenshot, waitForEvent} from '../utilities.js';
suite('Color Grade Effect', () => {
let element: ModelViewerElement;
let composer: EffectComposer;
let baseScreenshot: Uint8Array;
let colorGrade: ColorGradeEffect;
setup(async () => {
element = createModelViewerElement(assetPath('models/Astronaut.glb'));
composer = new EffectComposer();
element.insertBefore(composer, element.firstChild);
await waitForEvent(element, 'load');
baseScreenshot = screenshot(element);
colorGrade = new ColorGradeEffect();
composer.insertBefore(colorGrade, composer.firstChild);
});
teardown(() => {
document.body.removeChild(element);
});
test('Color Grade Affects Pixels', async () => {
colorGrade.contrast = 1.0;
await composer.updateComplete;
await rafPasses();
const colorGradeScreenshot = screenshot(element);
expect(ArraysAreEqual(baseScreenshot, colorGradeScreenshot)).to.be.false;
expect(CompareArrays(baseScreenshot, colorGradeScreenshot))
.to.be.lessThan(0.98);
});
test('Saturation = 0', async () => {
colorGrade.saturation = -1;
await composer.updateComplete;
await rafPasses();
const colorGradeScreenshot = screenshot(element);
const hslBefore = AverageHSL(baseScreenshot);
const hslAfter = AverageHSL(colorGradeScreenshot);
expect(hslBefore.s).to.be.greaterThan(hslAfter.s);
expect(hslAfter.s).to.be.closeTo(0, 0.01);
});
test('Brightness = 0', async () => {
colorGrade.brightness = -1;
await composer.updateComplete;
await rafPasses();
const colorGradeScreenshot = screenshot(element);
const hslBefore = AverageHSL(baseScreenshot);
const hslAfter = AverageHSL(colorGradeScreenshot);
expect(hslBefore.l).to.be.greaterThan(hslAfter.l);
expect(hslAfter.l).to.be.eq(0);
});
test('Hue difference', async () => {
colorGrade.brightness = colorGrade.contrast = colorGrade.saturation = 0;
colorGrade.hue = 2;
await composer.updateComplete;
await rafPasses();
const colorGradeScreenshot = screenshot(element);
const hslBefore = AverageHSL(baseScreenshot);
const hslAfter = AverageHSL(colorGradeScreenshot);
expect(hslBefore.h).to.not.eq(hslAfter.h);
});
});
@@ -0,0 +1,16 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './color-grade-spec.js';
@@ -0,0 +1,18 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './utilities-spec.js';
import './effect-composer-spec.js';
import './effects.js';
@@ -0,0 +1,79 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {ModelViewerElement} from '@google/model-viewer';
import {Renderer} from '@google/model-viewer/lib/three-components/Renderer.js';
import {expect} from 'chai';
import {$effectComposer} from '../effect-composer.js';
import {EffectComposer} from '../model-viewer-effects.js';
import {getOwnPropertySymbolValue} from '../utilities.js';
import {ArraysAreEqual, assetPath, createModelViewerElement, screenshot, timePasses, waitForEvent} from './utilities.js';
suite('Screenshot Baseline Test', () => {
let element: ModelViewerElement;
let baseScreenshot: Uint8Array;
setup(async () => {
element = createModelViewerElement(assetPath('models/Astronaut.glb'));
await waitForEvent(element, 'load');
});
teardown(() => {
document.body.removeChild(element);
});
test('Compare ModelViewer to Self', async () => {
const renderer =
getOwnPropertySymbolValue<Renderer>(element, 'renderer') as Renderer;
expect(renderer).to.not.be.undefined;
expect(renderer.threeRenderer).to.not.be.undefined;
await timePasses(5);
baseScreenshot = screenshot(element);
await timePasses(5);
const screenshot2 = screenshot(element);
expect(ArraysAreEqual(baseScreenshot, screenshot2)).to.be.true;
});
suite('<effect-composer>', () => {
let composer: EffectComposer;
let composerScreenshot: Uint8Array;
setup(async () => {
composer = new EffectComposer();
composer.renderMode = 'quality';
composer.msaa = 8;
element.insertBefore(composer, element.firstChild);
await timePasses(5);
});
test('Compare Self', async () => {
const renderer = composer[$effectComposer].getRenderer();
expect(renderer).to.not.be.undefined;
await timePasses(10);
composerScreenshot = screenshot(element);
await timePasses(10);
const screenshot2 = screenshot(element);
expect(ArraysAreEqual(composerScreenshot, screenshot2)).to.be.true;
});
test('Empty EffectComposer and base Renderer are identical', () => {
expect(ArraysAreEqual(baseScreenshot, composerScreenshot)).to.be.true;
});
});
});
@@ -0,0 +1,334 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {ModelViewerElement} from '@google/model-viewer';
import {Renderer} from '@google/model-viewer/lib/three-components/Renderer.js';
import {HSL} from 'three';
import {getOwnPropertySymbolValue} from '../utilities.js';
export const timePasses = (ms: number = 0): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
/**
* Converts a partial URL string to a fully qualified URL string.
*
* @param {String} url
* @return {String}
*/
export const toFullUrl = (partialUrl: string): string => {
const url = new URL(partialUrl, window.location.toString());
return url.toString();
};
export const deserializeUrl = (url: string|null): string|null =>
(!!url && url !== 'null' ? toFullUrl(url) : null);
export const elementFromLocalPoint =
(document: Document|ShadowRoot, x: number, y: number): Element|null => {
const host: HTMLElement = document === window.document ?
window.document.body :
((document as ShadowRoot).host as HTMLElement);
const actualDocument =
(window as any).ShadyCSS ? window.document : document;
const boundingRect = host.getBoundingClientRect();
return actualDocument.elementFromPoint(
boundingRect.left + x, boundingRect.top + y);
};
export const pickShadowDescendant =
(element: Element, x: number = 0, y: number = 0): Element|null => {
return element.shadowRoot != null ?
elementFromLocalPoint(element.shadowRoot, x, y) :
null;
};
export const rafPasses = (): Promise<void> =>
new Promise((resolve) => requestAnimationFrame(() => resolve()));
export interface SyntheticEventProperties {
clientX?: number;
clientY?: number;
deltaY?: number;
key?: string;
shiftKey?: boolean;
}
/**
* Dispatch a synthetic event on a given element with a given type, and
* optionally with custom event properties. Returns the dispatched event.
*
* @param {HTMLElement} element
* @param {type} string
* @param {*} properties
*/
export const dispatchSyntheticEvent =
(target: EventTarget, type: string, properties: SyntheticEventProperties = {
clientX: 0,
clientY: 0,
deltaY: 1.0,
}): CustomEvent => {
const event = new CustomEvent(type, {cancelable: true, bubbles: true});
Object.assign(event, properties);
target.dispatchEvent(event);
return event;
};
export const ASSETS_DIRECTORY = 'packages/shared-assets/';
/**
* Returns the full path for an asset by name. This is a convenience helper so
* that we don't need to change paths throughout all test suites if we ever
* decide to move files around.
*
* @param {string} name
* @return {string}
*/
export const assetPath = (name: string): string =>
deserializeUrl(`${ASSETS_DIRECTORY}${name}`)!;
/**
* Returns true if the given element is in the tree of the document of the
* current frame.
*
* @param {HTMLElement} element
* @return {boolean}
*/
export const isInDocumentTree = (node: Node): boolean => {
let root: Node = node.getRootNode();
while (root !== node && root != null) {
if (root.nodeType === Node.DOCUMENT_NODE) {
return root === document;
}
root = (root as ShadowRoot).host && (root as ShadowRoot).host.getRootNode();
}
return false;
};
/**
* "Spies" on a particular object by replacing a specified part of its
* implementation with a custom version. Returns a function that restores the
* original implementation to the object when invoked.
*/
export const spy =
(object: Object, property: string, descriptor: PropertyDescriptor): (
() => void) => {
let sourcePrototype = object;
while (sourcePrototype != null &&
!sourcePrototype.hasOwnProperty(property)) {
sourcePrototype = (sourcePrototype as any).__proto__;
}
if (sourcePrototype == null) {
throw new Error(`Cannot spy property "${property}" on ${object}`);
}
const originalDescriptor =
Object.getOwnPropertyDescriptor(sourcePrototype, property);
if (originalDescriptor == null) {
throw new Error(`Cannot read descriptor of "${property}" on ${object}`);
}
Object.defineProperty(sourcePrototype, property, descriptor);
return () => {
Object.defineProperty(sourcePrototype, property, originalDescriptor);
};
};
/**
* Creates a ModelViewerElement with a given src, attaches to document as first
* child and returns
* @param src Model to load
* @returns element
*/
export const createModelViewerElement =
(src: string|null): ModelViewerElement => {
const element = new ModelViewerElement();
document.body.insertBefore(element, document.body.firstChild);
element.src = src;
return element;
};
export type PredicateFunction<T = void> = (value: T) => boolean;
/**
* @param {EventTarget|EventDispatcher} target
* @param {string} eventName
* @param {?Function} predicate
*/
export const waitForEvent =
<T>(target: any,
eventName: string,
predicate: PredicateFunction<T>|null = null): Promise<T> =>
new Promise((resolve) => {
function handler(event: T) {
if (!predicate || predicate(event)) {
resolve(event);
target.removeEventListener(eventName, handler);
}
}
target.addEventListener(eventName, handler);
});
export interface TypedArray<T = unknown> {
readonly BYTES_PER_ELEMENT: number;
length: number;
[n: number]: T;
reduce(
callbackfn:
(previousValue: number, currentValue: number, currentIndex: number,
array: TypedArray<number>) => number,
initialValue?: number): number;
}
const COMPONENTS_PER_PIXEL = 4;
export function screenshot(element: ModelViewerElement): Uint8Array {
const renderer = getOwnPropertySymbolValue<Renderer>(element, 'renderer');
if (!renderer)
throw new Error('Invalid element provided');
const screenshotContext = renderer.threeRenderer.getContext();
const width = screenshotContext.drawingBufferWidth;
const height = screenshotContext.drawingBufferHeight;
const pixels = new Uint8Array(width * height * COMPONENTS_PER_PIXEL);
// this function reads in the bottom-up direction from the coordinate
// specified ((0,0) is the bottom-left corner).
screenshotContext.readPixels(
0,
0,
width,
height,
screenshotContext.RGBA,
screenshotContext.UNSIGNED_BYTE,
pixels);
return pixels;
}
export function ArraysAreEqual(arr1: TypedArray, arr2: TypedArray): boolean {
if (arr1.length !== arr2.length)
return false;
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i])
return false;
}
return true;
}
/*
* Compares two
* @param arr1
* @param arr2
* @returns Percentage of similarity (0-1), higher is better
*/
export function CompareArrays(
arr1: TypedArray<number>, arr2: TypedArray<number>): number {
if (arr1.length !== arr2.length ||
arr1.BYTES_PER_ELEMENT !== arr2.BYTES_PER_ELEMENT)
return 0;
const similarity: number[] = [];
const max = maxValue(arr1.BYTES_PER_ELEMENT);
for (let i = 0; i < arr1.length; i += COMPONENTS_PER_PIXEL) {
if (arr1[i + 3] != 0 && arr2[i + 3] != 0) { // a
similarity.push(1 - percentage(arr1[i], arr2[i], max)); // r
similarity.push(1 - percentage(arr1[i + 1], arr2[i + 1], max)); // g
similarity.push(1 - percentage(arr1[i + 2], arr2[i + 2], max)); // b
}
}
return average(similarity);
}
export function AverageHSL(arr: TypedArray<number>): HSL {
const H: number[] = [];
const S: number[] = [];
const L: number[] = [];
for (let i = 0; i < arr.length; i += COMPONENTS_PER_PIXEL) {
if (arr[i + 3] != 0) {
// a
const hsl = rgbToHsl(arr[i], arr[i + 1], arr[i + 2]);
H.push(hsl.h);
S.push(hsl.s);
L.push(hsl.l);
}
}
return {h: average(H), s: average(S), l: average(L)};
}
function maxValue(bytes: number): number {
return Math.pow(2, 8 * bytes) - 1;
}
function percentage(n1: number, n2: number, maxN: number): number {
return Math.abs(n1 - n2) / maxN;
}
function average(arr: number[]): number {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
/**
* Converts an RGB color value to HSL. Conversion formula
* adapted from http://en.wikipedia.org/wiki/HSL_color_space.
* Assumes r, g, and b are contained in the set [0, 255] and
* returns h, s, and l in the set [0, 1].
*
* @param Number r The red color value
* @param Number g The green color value
* @param Number b The blue color value
* @return Array The HSL representation
*/
function rgbToHsl(r: number, g: number, b: number): HSL {
(r /= 255), (g /= 255), (b /= 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max == min) {
h = s = 0; // achromatic
} else {
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
default:
throw new Error('invalid rgb');
}
h /= 6;
}
return {h, s, l};
}
@@ -0,0 +1,154 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Effect, EffectAttribute, EffectPass, Pass } from 'postprocessing';
import { ColorRepresentation } from 'three';
export type Constructor<T = object, U = object> = {
new (...args: any[]): T;
prototype: T;
} & U;
/**
* Get symbol of given key if exists on object.
* @param object Object to retrieve symbol from
* @param key Key to search for (case sensitive)
* @returns `Symbol(key)`
*/
export function getOwnPropertySymbol(object: any, key: string): symbol | undefined {
while (object) {
const symbol = Object.getOwnPropertySymbols(object).find((symbol) => symbol.description === key);
if (symbol) return symbol;
// Search further up in prototype chain
object = Object.getPrototypeOf(object);
}
return;
}
/**
* Determines whether an object has a Symbol property with the specified key.
* @param object Object to retrieve symbol from
* @param key Key to search for (case sensitive)
*/
export function hasOwnPropertySymbol(object: any, key: string): boolean {
return getOwnPropertySymbol(object, key) !== undefined;
}
/**
* Get value of symbol of given key if exists on object.
* @param object Object to retrieve key value from
* @param key Key to search for (case sensitive)
* @returns `object[Symbol(key)]`
*/
export function getOwnPropertySymbolValue<T = unknown>(object: any, key: string): T | undefined {
const symbol = getOwnPropertySymbol(object, key);
return symbol && object[symbol];
}
/**
* @param {Number} value
* @param {Number} lowerLimit
* @param {Number} upperLimit
* @return {Number} value clamped within `lowerLimit - upperLimit`
*/
export function clamp(value: number, lowerLimit: number, upperLimit: number): number {
return Math.max(lowerLimit, Math.min(upperLimit, value));
}
/**
* @param {Number} value
* @returns value clamped between `0 - 1`
*/
export function clampNormal(value: number): number {
return clamp(value, 0, 1);
}
/**
* @param {Number} value
* @param {Number} lowerLimit
* @param {Number} upperLimit
* @return {Number} wraps value between `lowerLimit - upperLimit`
*/
export function wrapClamp(value: number, lowerLimit: number, upperLimit: number): number {
if (value > upperLimit) return lowerLimit;
if (value < lowerLimit) return upperLimit;
return value;
}
/**
* Searches through hierarchy of HTMLElement until an element with a non-transparent background is found
* @param elem The element background to get
* @returns The backgroundColor
*/
export function getBackgroundColor(elem: HTMLElement): ColorRepresentation | undefined {
let currElem: HTMLElement | null = elem;
while (currElem && isTransparent(getComputedStyle(currElem))) {
currElem = currElem.parentElement;
}
if (!currElem) return;
return getComputedStyle(currElem).backgroundColor as ColorRepresentation;
}
/**
* Determines whether an Element's backgroundColor is transparent
* @param style The CSS properties of an Element
*/
function isTransparent(style: CSSStyleDeclaration): boolean {
return style.backgroundColor === 'transparent' || style.backgroundColor === 'rgba(0, 0, 0, 0)' || !style.backgroundColor;
}
/**
* Determines whether the given Effect uses Convolution.
* @param effect The effect to check.
*/
export function isConvolution(effect: Effect): boolean {
return (effect.getAttributes() & EffectAttribute.CONVOLUTION) != 0;
}
/**
* Disposes of Pass properties without disposing of the Effects.
* @param pass Pass to dispose of
*/
export function disposeEffectPass(pass: EffectPass): void {
Pass.prototype.dispose.call(pass);
if (!(pass as any).listener) return;
for (const effect of (pass as any).effects) {
effect.removeEventListener('change', (pass as any).listener);
}
}
export function getValueOfEnum<T extends Object>(Enum: T, key: string): T {
const index = Object.keys(Enum)
.filter((v) => !isNaN(Number(v)))
.indexOf(key);
return (Enum as any)[index];
}
/**
* Helper function to validate whether a value is in-fact a valid option of a literal type.
*
* Requires the type to be defined as follows:
* @code
* `const TOptions = [...] as const;`
*
* `type T = typeof TOptions[number];`
* @param options `TOptions`
* @param value `value: T`
* @throws TypeError
*/
export function validateLiteralType<TOptions extends readonly unknown[]>(options: TOptions, value: typeof options[number]): void {
if (!options.includes(value)) throw new TypeError(`Validation Error: ${value} is not a valid value. Expected ${options.join(' | ')}`);
}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es2017",
"module": "es2015",
"moduleResolution": "bundler",
"lib": [
"es2017",
"dom"
],
"sourceMap": true,
"inlineSources": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"outDir": "./lib",
"experimentalDecorators": true,
"noErrorTruncation": true,
"declaration": true,
"esModuleInterop": true,
},
"include": [
"./src/**/*.ts"
],
"exclude": []
}
@@ -0,0 +1,41 @@
// import {esbuildPlugin} from '@web/dev-server-esbuild';
import {devices, playwrightLauncher} from '@web/test-runner-playwright';
export default {
concurrency: 10,
nodeResolve: true,
files: 'lib/test/**/*-spec.js',
// in a monorepo you need to set set the root dir to resolve modules
rootDir: '../../',
browserLogs: false,
filterBrowserLogs:
(log) => {
return log.type === 'error';
},
testRunnerHtml: testFramework => `
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<script type="module" src="${testFramework}"></script>
</body>
</html>`,
testsFinishTimeout: 300000,
testFramework: {
config: {
ui: 'tdd',
timeout: '120000',
},
},
// plugins: [esbuildPlugin({ts: true})],
// browsers:
// [
// playwrightLauncher({
// product: 'webkit',
// createBrowserContext({browser}) {
// return browser.newContext({...devices['iPhone X']});
// },
// }),
// ],
};