meccha-chameleon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/package.json +53 -0
- package/src/meccha-chameleon.js +459 -0
- package/src/meccha-chameleon.mjs +455 -0
- package/src/presets/mecha1.js +15 -0
- package/src/presets/mecha1.mjs +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Esteve Segura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/EsteveSegura/MecchaChameleon.js/main/docs/demo.gif" width="720" alt="MecchaChameleon.js demo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# MecchaChameleon.js
|
|
6
|
+
|
|
7
|
+
A tiny WebGL overlay that lights a 2D figure from a **normal map** (relief +
|
|
8
|
+
specular highlights) and floats it above your page. It's **non-interactive**
|
|
9
|
+
(`pointer-events: none`) — purely visual. Optionally it can paint the real page
|
|
10
|
+
behind the figure with a live **brush** effect and overlay a **shadow** texture.
|
|
11
|
+
|
|
12
|
+
Zero dependencies. Ships as native **ESM** (`import`) and **CommonJS/UMD**
|
|
13
|
+
(`require` and `<script>`), and is **tree-shakeable** (`"sideEffects": false`,
|
|
14
|
+
no top-level side effects). The core ships no images (bring your own); optional
|
|
15
|
+
presets with embedded images are available on demand.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install meccha-chameleon
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import MecchaChameleon from 'meccha-chameleon';
|
|
25
|
+
|
|
26
|
+
MecchaChameleon.mount({
|
|
27
|
+
image: 'figure.png', // base color PNG (with alpha)
|
|
28
|
+
normalMap: 'figure-normal.png',// normal map aligned to the figure
|
|
29
|
+
target: '#hero', width: 200 // anchor the figure over #hero
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Named imports also work:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { mount, update, unmount } from 'meccha-chameleon';
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Batteries-included presets (optional)
|
|
40
|
+
|
|
41
|
+
Don't have a figure yet? Import a ready-made preset — the images come embedded
|
|
42
|
+
as data URIs, so there's nothing to copy into `public/`, no bundler asset
|
|
43
|
+
config, and no `file://` / CORS texture issues:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
import MecchaChameleon from 'meccha-chameleon';
|
|
47
|
+
import mecha1 from 'meccha-chameleon/presets/mecha1';
|
|
48
|
+
|
|
49
|
+
MecchaChameleon.mount({ ...mecha1, target: '#hero' });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Presets are **separate modules**: importing the core library never pulls them
|
|
53
|
+
in, so they stay tree-shakeable. A bundler only ships a preset's images if you
|
|
54
|
+
actually import that subpath (verified: a core-only build is ~20 KB; adding the
|
|
55
|
+
preset adds ~2.5 MB — nothing in between).
|
|
56
|
+
|
|
57
|
+
Or with a plain script tag (global `MecchaChameleon`):
|
|
58
|
+
|
|
59
|
+
```html
|
|
60
|
+
<script src="https://unpkg.com/meccha-chameleon"></script>
|
|
61
|
+
<script>
|
|
62
|
+
MecchaChameleon.mount({ image: 'figure.png', normalMap: 'figure-normal.png' });
|
|
63
|
+
</script>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
> **WebGL note:** browsers will not load textures from `file://`. Serve your
|
|
67
|
+
> page over HTTP (`npx http-server`, Vite, etc.).
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
- `MecchaChameleon.mount(config)` — create the overlay.
|
|
72
|
+
- `MecchaChameleon.update(partialConfig)` — change parameters **live** (light,
|
|
73
|
+
opacity, shadow, target…). Structural changes (`brush` and brush params) need
|
|
74
|
+
a fresh `mount()`.
|
|
75
|
+
- `MecchaChameleon.unmount()` — remove it and detach all listeners.
|
|
76
|
+
|
|
77
|
+
## Config
|
|
78
|
+
|
|
79
|
+
Two assets are **required**: `image` (base color) and `normalMap`.
|
|
80
|
+
|
|
81
|
+
| Key | Default | What it does |
|
|
82
|
+
|-----|---------|--------------|
|
|
83
|
+
| `image` | — | Base color PNG (with alpha). **Required.** |
|
|
84
|
+
| `normalMap` | — | Normal map, same framing as the figure. **Required.** |
|
|
85
|
+
| `target` | `null` | CSS selector (`#id`/`.class`) the figure is centered over. It tracks the element on scroll/resize. `null` or a missing element falls back to the viewport center. |
|
|
86
|
+
| `width` | `200` | Width in px (height keeps the image aspect). |
|
|
87
|
+
| `animateLight` | `false` | Light orbits on its own. |
|
|
88
|
+
| `lightIntensity` | `0.45` | Light intensity. |
|
|
89
|
+
| `ambient` | `0.22` | Fill light (0 = black shadows). |
|
|
90
|
+
| `specularStrength` | `0.75` | Specular strength. |
|
|
91
|
+
| `specularHardness` | `22` | Specular hardness (higher = smaller highlight). |
|
|
92
|
+
| `relief` | `1` | Exaggerates the normal map. |
|
|
93
|
+
| `lightZ` | `1.4` | Light height (lower = grazing = more relief). |
|
|
94
|
+
| `lightRadius` | `0.35` | Orbit radius. |
|
|
95
|
+
| `lightSpeed` | `0.35` | Orbit speed (turns/sec). |
|
|
96
|
+
| `lightAngle` | `-0.7` | Fixed light angle (rad) when `animateLight:false`. |
|
|
97
|
+
| `lightColor` | `'#ffffff'` | Light color. |
|
|
98
|
+
| `tint` | `null` | Hex color multiplied over the base. |
|
|
99
|
+
| `opacity` | `0.2` | Figure opacity. |
|
|
100
|
+
| `blendMode` | `'normal'` | Figure `mix-blend-mode`. |
|
|
101
|
+
| `brush` | `true` | Paint the real background inside the silhouette. |
|
|
102
|
+
| `brushStrength` | `24` | Smear strength (px). |
|
|
103
|
+
| `brushPosterize` | `17` | Color levels per channel (paint patches). 0 = off. |
|
|
104
|
+
| `brushGrain` | `0.1` | Canvas/bristle grain (0–1). |
|
|
105
|
+
| `brushUsesNormalMap` | `false` | `true` = rigid glass-like refraction following the shape. |
|
|
106
|
+
| `shadow` | `null` | Shadow texture PNG (aligned to the figure). |
|
|
107
|
+
| `shadowOpacity` | `0.3` | Shadow opacity. |
|
|
108
|
+
| `shadowBlendMode` | `'normal'` | Shadow `mix-blend-mode`. |
|
|
109
|
+
|
|
110
|
+
## Package layout
|
|
111
|
+
|
|
112
|
+
- `src/meccha-chameleon.mjs` — native ESM (used by `import` / bundlers).
|
|
113
|
+
- `src/meccha-chameleon.js` — CommonJS/UMD (used by `require` and CDN `<script>`).
|
|
114
|
+
- `src/presets/*` — optional ready-made figures with embedded images, imported
|
|
115
|
+
on demand via `meccha-chameleon/presets/<name>`.
|
|
116
|
+
|
|
117
|
+
Only `src/` (plus README and LICENSE) is published; the playground itself is not
|
|
118
|
+
part of the package.
|
|
119
|
+
|
|
120
|
+
## Playground / config generator
|
|
121
|
+
|
|
122
|
+
`playground/index.html` is a visual editor: tune the sliders and it prints the
|
|
123
|
+
exact `MecchaChameleon.mount({...})` config for the current look, with a **Copy**
|
|
124
|
+
button. Serve it over HTTP:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm run playground
|
|
128
|
+
# or: npx http-server -c-1 -o playground/
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The playground uses demo images under `playground/assets/` (tracked in the repo,
|
|
132
|
+
but excluded from the npm package). Drop your own `image`, `normalMap` and
|
|
133
|
+
(optional) `shadow` PNGs there to try your own figure.
|
|
134
|
+
|
|
135
|
+
## Browser support
|
|
136
|
+
|
|
137
|
+
`brush` uses `backdrop-filter` + SVG filters — solid in Chromium/Safari; the
|
|
138
|
+
`url()` reference in `backdrop-filter` is weak in Firefox (the lit figure still
|
|
139
|
+
works, the painted background may not).
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "meccha-chameleon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WebGL overlay that lights a 2D figure from a normal map (relief + specular), with an optional live brush-painted background and shadow layer. Floats above the page and is non-interactive.",
|
|
5
|
+
"homepage": "https://github.com/EsteveSegura/MecchaChameleon.js#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/EsteveSegura/MecchaChameleon.js.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/EsteveSegura/MecchaChameleon.js/issues"
|
|
12
|
+
},
|
|
13
|
+
"author": "Esteve Segura",
|
|
14
|
+
"type": "commonjs",
|
|
15
|
+
"main": "src/meccha-chameleon.js",
|
|
16
|
+
"module": "src/meccha-chameleon.mjs",
|
|
17
|
+
"unpkg": "src/meccha-chameleon.js",
|
|
18
|
+
"jsdelivr": "src/meccha-chameleon.js",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"import": "./src/meccha-chameleon.mjs",
|
|
22
|
+
"require": "./src/meccha-chameleon.js",
|
|
23
|
+
"default": "./src/meccha-chameleon.js"
|
|
24
|
+
},
|
|
25
|
+
"./presets/*": {
|
|
26
|
+
"import": "./src/presets/*.mjs",
|
|
27
|
+
"require": "./src/presets/*.js",
|
|
28
|
+
"default": "./src/presets/*.js"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"keywords": [
|
|
39
|
+
"webgl",
|
|
40
|
+
"normal-map",
|
|
41
|
+
"overlay",
|
|
42
|
+
"shader",
|
|
43
|
+
"lighting",
|
|
44
|
+
"specular",
|
|
45
|
+
"canvas",
|
|
46
|
+
"brush",
|
|
47
|
+
"painterly"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"playground": "http-server -c-1 -p 8080 -o playground/"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT"
|
|
53
|
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MecchaChameleon — WebGL engine
|
|
3
|
+
* Drops YOUR figure onto the page as a visual overlay and LIGHTS it in 3D from
|
|
4
|
+
* a normal map: relief, diffuse shading and specular highlights with a light
|
|
5
|
+
* that moves on its own. It can optionally paint the real page behind it with a
|
|
6
|
+
* live "brush" effect and overlay a shadow texture. Non-interactive (visual only).
|
|
7
|
+
*
|
|
8
|
+
* Install:
|
|
9
|
+
* npm install meccha-chameleon
|
|
10
|
+
*
|
|
11
|
+
* As a module (bundler / ESM):
|
|
12
|
+
* import MecchaChameleon from 'meccha-chameleon';
|
|
13
|
+
* MecchaChameleon.mount({ image:'mecha_chameleon_pose1_albedo.png', normalMap:'mecha_chameleon_pose1_normal.png', target:'#hero', width:200 });
|
|
14
|
+
*
|
|
15
|
+
* As a <script> tag (global MecchaChameleon):
|
|
16
|
+
* <script src="meccha-chameleon.js"></script>
|
|
17
|
+
* <script>MecchaChameleon.mount({ image:'mecha_chameleon_pose1_albedo.png', normalMap:'mecha_chameleon_pose1_normal.png' });</script>
|
|
18
|
+
*
|
|
19
|
+
* NOTE: WebGL cannot load textures from file://. Serve the page over HTTP.
|
|
20
|
+
*/
|
|
21
|
+
(function (root, factory) {
|
|
22
|
+
if (typeof module === 'object' && module.exports) module.exports = factory(); // CommonJS
|
|
23
|
+
else if (typeof define === 'function' && define.amd) define([], factory); // AMD
|
|
24
|
+
else root.MecchaChameleon = factory(); // <script> global
|
|
25
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
var DEFAULTS = {
|
|
29
|
+
image: null, // (REQUIRED) base color PNG (with alpha)
|
|
30
|
+
normalMap: null, // (REQUIRED) normal map aligned to the figure
|
|
31
|
+
|
|
32
|
+
target: null, // CSS selector (#id / .class) the figure is anchored to
|
|
33
|
+
// (centered over that element). Falls back to the
|
|
34
|
+
// viewport center when null or the element is missing.
|
|
35
|
+
width: 200, // width in px (height keeps the image aspect ratio)
|
|
36
|
+
|
|
37
|
+
// --- Light ---
|
|
38
|
+
animateLight: false, // the light orbits on its own
|
|
39
|
+
lightSpeed: 0.35, // turns per second
|
|
40
|
+
lightRadius: 0.35, // orbit radius (0..~1.5)
|
|
41
|
+
lightAngle: -0.7, // fixed angle (rad) when animateLight = false
|
|
42
|
+
lightZ: 1.4, // light height/closeness (lower = grazing = more relief)
|
|
43
|
+
lightColor: '#ffffff',
|
|
44
|
+
lightIntensity: 0.45,
|
|
45
|
+
|
|
46
|
+
ambient: 0.22, // fill light (0 = black shadows)
|
|
47
|
+
specularStrength: 0.75,// highlight strength
|
|
48
|
+
specularHardness: 22, // highlight size (higher = smaller spot)
|
|
49
|
+
|
|
50
|
+
relief: 1, // exaggerates the normal map (1 = as-is)
|
|
51
|
+
tint: null, // hex color multiplied over the base color (e.g. '#8fd0ff')
|
|
52
|
+
|
|
53
|
+
// --- Brush-painted background (CSS layer beneath the figure) ---
|
|
54
|
+
brush: true, // paints the real background inside the silhouette
|
|
55
|
+
brushUsesNormalMap: false, // true = rigid refraction following the shape (glass);
|
|
56
|
+
// false = noise smear (brush strokes, more "painted")
|
|
57
|
+
brushStrength: 24, // smear/displacement in px (the stroke drags the background)
|
|
58
|
+
brushFrequency: 0.03, // stroke size: lower = thicker stroke
|
|
59
|
+
brushOctaves: 2,
|
|
60
|
+
brushBlur: 1.0, // blur in px -> wet-paint look
|
|
61
|
+
brushPosterize: 17, // tones per channel (flattens color into patches). 0 = off
|
|
62
|
+
brushGrain: 0.1, // canvas/bristle texture (0 = smooth, 1 = strong)
|
|
63
|
+
brushSaturation: 1.25,
|
|
64
|
+
brushContrast: 1.08,
|
|
65
|
+
|
|
66
|
+
// --- Figure layer ---
|
|
67
|
+
opacity: 0.2, // lower (e.g. 0.5) to see the painted background through the figure
|
|
68
|
+
blendMode: 'normal', // canvas mix-blend-mode ('multiply', 'screen'...)
|
|
69
|
+
|
|
70
|
+
// --- Shadow layer (PNG texture on top of everything) ---
|
|
71
|
+
shadow: null, // shadow PNG URL (transparent, aligned to the figure)
|
|
72
|
+
shadowOpacity: 0.3, // individual shadow opacity
|
|
73
|
+
shadowBlendMode: 'normal', // shadow mix-blend-mode
|
|
74
|
+
|
|
75
|
+
zIndex: 2147483000,
|
|
76
|
+
id: 'meccha-chameleon-overlay'
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
var VERT =
|
|
80
|
+
'attribute vec2 aPos;varying vec2 vUv;' +
|
|
81
|
+
'void main(){vUv=vec2(aPos.x*0.5+0.5,1.0-(aPos.y*0.5+0.5));' +
|
|
82
|
+
'gl_Position=vec4(aPos,0.0,1.0);}';
|
|
83
|
+
|
|
84
|
+
var FRAG =
|
|
85
|
+
'precision mediump float;varying vec2 vUv;' +
|
|
86
|
+
'uniform sampler2D uAlbedo;uniform sampler2D uNormal;' +
|
|
87
|
+
'uniform vec3 uLightPos;uniform vec3 uLightColor;' +
|
|
88
|
+
'uniform float uIntensity;uniform float uAmbient;' +
|
|
89
|
+
'uniform float uSpecStrength;uniform float uShininess;' +
|
|
90
|
+
'uniform float uRelief;uniform vec3 uTint;uniform float uUseTint;' +
|
|
91
|
+
'void main(){' +
|
|
92
|
+
' vec4 albedo=texture2D(uAlbedo,vUv);' +
|
|
93
|
+
' if(albedo.a<0.01) discard;' +
|
|
94
|
+
' vec3 nTex=texture2D(uNormal,vUv).rgb*2.0-1.0;' +
|
|
95
|
+
' nTex.y=-nTex.y;' + // flip G to screen space (y goes down)
|
|
96
|
+
' nTex.xy*=uRelief;' +
|
|
97
|
+
' vec3 N=normalize(nTex);' +
|
|
98
|
+
' vec3 fragPos=vec3(vUv*2.0-1.0,0.0);' +
|
|
99
|
+
' vec3 L=normalize(uLightPos-fragPos);' +
|
|
100
|
+
' vec3 V=vec3(0.0,0.0,1.0);' +
|
|
101
|
+
' vec3 H=normalize(L+V);' +
|
|
102
|
+
' float diff=max(dot(N,L),0.0);' +
|
|
103
|
+
' float spec=pow(max(dot(N,H),0.0),uShininess)*uSpecStrength;' +
|
|
104
|
+
' vec3 base=albedo.rgb;' +
|
|
105
|
+
' if(uUseTint>0.5) base*=uTint;' +
|
|
106
|
+
' vec3 color=base*(uAmbient+diff*uLightColor*uIntensity)+spec*uLightColor;' +
|
|
107
|
+
' gl_FragColor=vec4(color,albedo.a);' +
|
|
108
|
+
'}';
|
|
109
|
+
|
|
110
|
+
function hexRgb(h) {
|
|
111
|
+
h = String(h).replace('#', '');
|
|
112
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
113
|
+
var n = parseInt(h, 16);
|
|
114
|
+
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Injects the SVG <filter> that PAINTS the real background (brush effect):
|
|
118
|
+
// noise smear -> blur -> posterize (color patches) -> canvas grain.
|
|
119
|
+
function createBrushFilter(cfg, uid) {
|
|
120
|
+
var NS = 'http://www.w3.org/2000/svg';
|
|
121
|
+
var svg = document.createElementNS(NS, 'svg');
|
|
122
|
+
Object.assign(svg.style, { position: 'absolute', width: '0', height: '0', overflow: 'hidden' });
|
|
123
|
+
var f = document.createElementNS(NS, 'filter');
|
|
124
|
+
var fid = 'meccha-chameleon-brush-' + uid;
|
|
125
|
+
f.setAttribute('id', fid);
|
|
126
|
+
f.setAttribute('x', '-20%'); f.setAttribute('y', '-20%');
|
|
127
|
+
f.setAttribute('width', '140%'); f.setAttribute('height', '140%');
|
|
128
|
+
f.setAttribute('color-interpolation-filters', 'sRGB');
|
|
129
|
+
|
|
130
|
+
function el(tag, attrs) {
|
|
131
|
+
var e = document.createElementNS(NS, tag);
|
|
132
|
+
for (var k in attrs) e.setAttribute(k, attrs[k]);
|
|
133
|
+
f.appendChild(e);
|
|
134
|
+
return e;
|
|
135
|
+
}
|
|
136
|
+
function transfer(inName, out, slope, intercept, discreteTable) {
|
|
137
|
+
var ct = el('feComponentTransfer', { in: inName, result: out });
|
|
138
|
+
['feFuncR', 'feFuncG', 'feFuncB'].forEach(function (fn) {
|
|
139
|
+
var fe = document.createElementNS(NS, fn);
|
|
140
|
+
if (discreteTable) { fe.setAttribute('type', 'discrete'); fe.setAttribute('tableValues', discreteTable); }
|
|
141
|
+
else { fe.setAttribute('type', 'linear'); fe.setAttribute('slope', slope); fe.setAttribute('intercept', intercept); }
|
|
142
|
+
ct.appendChild(fe);
|
|
143
|
+
});
|
|
144
|
+
return ct;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 1) Smear map: coarse noise (brush strokes) or normal map (rigid refraction).
|
|
148
|
+
if (cfg.brushUsesNormalMap && cfg.normalMap) {
|
|
149
|
+
var im = el('feImage', { x: '0', y: '0', width: '100%', height: '100%',
|
|
150
|
+
preserveAspectRatio: 'none', result: 'map' });
|
|
151
|
+
im.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', cfg.normalMap);
|
|
152
|
+
im.setAttribute('href', cfg.normalMap);
|
|
153
|
+
} else {
|
|
154
|
+
el('feTurbulence', { type: 'fractalNoise',
|
|
155
|
+
baseFrequency: cfg.brushFrequency + ' ' + cfg.brushFrequency,
|
|
156
|
+
numOctaves: cfg.brushOctaves, seed: '7', stitchTiles: 'stitch', result: 'map' });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2) Displace the background (drags the texture like a brush stroke).
|
|
160
|
+
el('feDisplacementMap', { in: 'SourceGraphic', in2: 'map',
|
|
161
|
+
scale: cfg.brushStrength, xChannelSelector: 'R', yChannelSelector: 'G', result: 'smear' });
|
|
162
|
+
|
|
163
|
+
// 3) Blur to fuse into wet-paint strokes.
|
|
164
|
+
el('feGaussianBlur', { in: 'smear', stdDeviation: cfg.brushBlur, result: 'soft' });
|
|
165
|
+
|
|
166
|
+
var current = 'soft';
|
|
167
|
+
|
|
168
|
+
// 4) Posterize: flattens color into flat patches (the essence of "painted").
|
|
169
|
+
if (cfg.brushPosterize && cfg.brushPosterize > 1) {
|
|
170
|
+
var n = Math.round(cfg.brushPosterize), tv = [];
|
|
171
|
+
for (var i = 0; i < n; i++) tv.push((i / (n - 1)).toFixed(4));
|
|
172
|
+
transfer('soft', 'post', null, null, tv.join(' '));
|
|
173
|
+
current = 'post';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 5) Canvas/bristle grain: fine gray noise multiplied on top.
|
|
177
|
+
if (cfg.brushGrain && cfg.brushGrain > 0) {
|
|
178
|
+
el('feTurbulence', { type: 'fractalNoise', baseFrequency: '0.7 0.7',
|
|
179
|
+
numOctaves: '2', seed: '11', result: 'gn0' });
|
|
180
|
+
// Noise -> opaque gray (RGB = luminance, alpha = 1), then compress its range.
|
|
181
|
+
el('feColorMatrix', { in: 'gn0', type: 'matrix', result: 'gg',
|
|
182
|
+
values: '0.33 0.33 0.33 0 0 0.33 0.33 0.33 0 0 0.33 0.33 0.33 0 0 0 0 0 0 1' });
|
|
183
|
+
var g = cfg.brushGrain;
|
|
184
|
+
transfer('gg', 'grain', String(g), String(1 - g)); // map [0,1] -> [1-g, 1] (mostly bright)
|
|
185
|
+
el('feBlend', { mode: 'multiply', in: current, in2: 'grain', result: 'painted' });
|
|
186
|
+
current = 'painted';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Final output (ensures the active result is the last primitive).
|
|
190
|
+
el('feOffset', { in: current, dx: '0', dy: '0' });
|
|
191
|
+
|
|
192
|
+
svg.appendChild(f);
|
|
193
|
+
document.body.appendChild(svg);
|
|
194
|
+
return { svg: svg, fid: fid };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function compileShader(gl, type, src) {
|
|
198
|
+
var s = gl.createShader(type);
|
|
199
|
+
gl.shaderSource(s, src);
|
|
200
|
+
gl.compileShader(s);
|
|
201
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
202
|
+
console.error('[meccha-chameleon] shader:', gl.getShaderInfoLog(s));
|
|
203
|
+
}
|
|
204
|
+
return s;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function loadTexture(gl, url, cb) {
|
|
208
|
+
var tex = gl.createTexture();
|
|
209
|
+
var img = new Image();
|
|
210
|
+
img.onload = function () {
|
|
211
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
212
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
213
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
214
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
215
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
216
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
217
|
+
cb(tex, img.naturalWidth, img.naturalHeight);
|
|
218
|
+
};
|
|
219
|
+
img.onerror = function () { console.error('[meccha-chameleon] failed to load', url); };
|
|
220
|
+
img.src = url;
|
|
221
|
+
return tex;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
var COUNTER = 0;
|
|
225
|
+
|
|
226
|
+
var MecchaChameleon = {
|
|
227
|
+
_state: null,
|
|
228
|
+
_raf: null,
|
|
229
|
+
_canvas: null,
|
|
230
|
+
_root: null,
|
|
231
|
+
_svg: null,
|
|
232
|
+
_shadow: null,
|
|
233
|
+
_ro: null,
|
|
234
|
+
_onReposition: null,
|
|
235
|
+
|
|
236
|
+
mount: function (options) {
|
|
237
|
+
this.unmount();
|
|
238
|
+
var cfg = Object.assign({}, DEFAULTS, options || {});
|
|
239
|
+
if (!cfg.image || !cfg.normalMap) {
|
|
240
|
+
console.error('[meccha-chameleon] Missing "image" and/or "normalMap".');
|
|
241
|
+
return this;
|
|
242
|
+
}
|
|
243
|
+
this._state = cfg;
|
|
244
|
+
var self = this;
|
|
245
|
+
var start = function () { self._init(cfg); };
|
|
246
|
+
if (document.readyState === 'loading') {
|
|
247
|
+
document.addEventListener('DOMContentLoaded', start, { once: true });
|
|
248
|
+
} else { start(); }
|
|
249
|
+
return this;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
_init: function (cfg) {
|
|
253
|
+
var dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
|
|
254
|
+
var uid = ++COUNTER;
|
|
255
|
+
var self = this;
|
|
256
|
+
|
|
257
|
+
// Container above everything (non-interactive). Its left/top/position are
|
|
258
|
+
// set by _place(), which anchors it to cfg.target (or the viewport center).
|
|
259
|
+
var root = document.createElement('div');
|
|
260
|
+
root.id = cfg.id;
|
|
261
|
+
Object.assign(root.style, {
|
|
262
|
+
width: cfg.width + 'px',
|
|
263
|
+
transform: 'translate(-50%,-50%)',
|
|
264
|
+
zIndex: String(cfg.zIndex),
|
|
265
|
+
pointerEvents: 'none'
|
|
266
|
+
});
|
|
267
|
+
document.body.appendChild(root);
|
|
268
|
+
this._root = root;
|
|
269
|
+
|
|
270
|
+
// Anchor to the target selector now and keep it in sync as the page
|
|
271
|
+
// scrolls / reflows / resizes.
|
|
272
|
+
this._place();
|
|
273
|
+
this._onReposition = function () { self._place(); };
|
|
274
|
+
window.addEventListener('resize', this._onReposition);
|
|
275
|
+
window.addEventListener('scroll', this._onReposition, { passive: true, capture: true });
|
|
276
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
277
|
+
this._ro = new ResizeObserver(this._onReposition);
|
|
278
|
+
this._ro.observe(document.documentElement);
|
|
279
|
+
var tgt = cfg.target && document.querySelector(cfg.target);
|
|
280
|
+
if (tgt) this._ro.observe(tgt);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Optional layer: the REAL background painted with the brush, clipped to the silhouette.
|
|
284
|
+
if (cfg.brush) {
|
|
285
|
+
var filter = createBrushFilter(cfg, uid);
|
|
286
|
+
this._svg = filter.svg;
|
|
287
|
+
var brushDiv = document.createElement('div');
|
|
288
|
+
var bf = 'url(#' + filter.fid + ') saturate(' + cfg.brushSaturation + ') contrast(' + cfg.brushContrast + ')';
|
|
289
|
+
var mask = 'url("' + cfg.image + '")';
|
|
290
|
+
Object.assign(brushDiv.style, {
|
|
291
|
+
position: 'absolute', inset: '0', pointerEvents: 'none',
|
|
292
|
+
WebkitBackdropFilter: bf, backdropFilter: bf,
|
|
293
|
+
WebkitMaskImage: mask, maskImage: mask,
|
|
294
|
+
WebkitMaskSize: '100% 100%', maskSize: '100% 100%',
|
|
295
|
+
WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat'
|
|
296
|
+
});
|
|
297
|
+
root.appendChild(brushDiv);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Lit figure layer (WebGL), above the brush.
|
|
301
|
+
var canvas = document.createElement('canvas');
|
|
302
|
+
Object.assign(canvas.style, {
|
|
303
|
+
position: 'relative', display: 'block',
|
|
304
|
+
width: '100%', height: 'auto',
|
|
305
|
+
pointerEvents: 'none',
|
|
306
|
+
opacity: String(cfg.opacity),
|
|
307
|
+
mixBlendMode: cfg.blendMode
|
|
308
|
+
});
|
|
309
|
+
root.appendChild(canvas);
|
|
310
|
+
this._canvas = canvas;
|
|
311
|
+
|
|
312
|
+
// Shadow layer (PNG) on top of everything, with its own opacity and blend mode.
|
|
313
|
+
if (cfg.shadow) {
|
|
314
|
+
var shadow = document.createElement('img');
|
|
315
|
+
shadow.src = cfg.shadow;
|
|
316
|
+
shadow.alt = '';
|
|
317
|
+
shadow.draggable = false;
|
|
318
|
+
Object.assign(shadow.style, {
|
|
319
|
+
position: 'absolute', left: '0', top: '0',
|
|
320
|
+
width: '100%', height: '100%',
|
|
321
|
+
pointerEvents: 'none', userSelect: 'none',
|
|
322
|
+
opacity: String(cfg.shadowOpacity),
|
|
323
|
+
mixBlendMode: cfg.shadowBlendMode
|
|
324
|
+
});
|
|
325
|
+
root.appendChild(shadow);
|
|
326
|
+
this._shadow = shadow;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
var gl = canvas.getContext('webgl', { premultipliedAlpha: false, alpha: true })
|
|
330
|
+
|| canvas.getContext('experimental-webgl');
|
|
331
|
+
if (!gl) { console.error('[meccha-chameleon] WebGL not available'); return; }
|
|
332
|
+
|
|
333
|
+
var prog = gl.createProgram();
|
|
334
|
+
gl.attachShader(prog, compileShader(gl, gl.VERTEX_SHADER, VERT));
|
|
335
|
+
gl.attachShader(prog, compileShader(gl, gl.FRAGMENT_SHADER, FRAG));
|
|
336
|
+
gl.linkProgram(prog);
|
|
337
|
+
gl.useProgram(prog);
|
|
338
|
+
|
|
339
|
+
var buf = gl.createBuffer();
|
|
340
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
341
|
+
gl.bufferData(gl.ARRAY_BUFFER,
|
|
342
|
+
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
|
343
|
+
var aPos = gl.getAttribLocation(prog, 'aPos');
|
|
344
|
+
gl.enableVertexAttribArray(aPos);
|
|
345
|
+
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
|
346
|
+
|
|
347
|
+
gl.enable(gl.BLEND);
|
|
348
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
349
|
+
|
|
350
|
+
var U = {};
|
|
351
|
+
['uLightPos', 'uLightColor', 'uIntensity', 'uAmbient', 'uSpecStrength',
|
|
352
|
+
'uShininess', 'uRelief', 'uTint', 'uUseTint', 'uAlbedo', 'uNormal']
|
|
353
|
+
.forEach(function (n) { U[n] = gl.getUniformLocation(prog, n); });
|
|
354
|
+
|
|
355
|
+
var ready = 0;
|
|
356
|
+
var texA = loadTexture(gl, cfg.image, function (t, w, h) {
|
|
357
|
+
// size the canvas to the image aspect ratio
|
|
358
|
+
var height = Math.round(cfg.width * (h / w));
|
|
359
|
+
canvas.style.height = height + 'px';
|
|
360
|
+
canvas.width = Math.round(cfg.width * dpr);
|
|
361
|
+
canvas.height = Math.round(height * dpr);
|
|
362
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
363
|
+
if (++ready === 2) start();
|
|
364
|
+
});
|
|
365
|
+
var texN = loadTexture(gl, cfg.normalMap, function () {
|
|
366
|
+
if (++ready === 2) start();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
function start() {
|
|
370
|
+
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texA);
|
|
371
|
+
gl.uniform1i(U.uAlbedo, 0);
|
|
372
|
+
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texN);
|
|
373
|
+
gl.uniform1i(U.uNormal, 1);
|
|
374
|
+
self._loop(gl, U);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
// Positions the root: centered over cfg.target, or the viewport center as
|
|
379
|
+
// a fallback. Uses absolute (document) coords so it scrolls with the target,
|
|
380
|
+
// and fixed coords for the viewport-center fallback.
|
|
381
|
+
_place: function () {
|
|
382
|
+
var cfg = this._state, root = this._root;
|
|
383
|
+
if (!cfg || !root) return;
|
|
384
|
+
var el = cfg.target ? document.querySelector(cfg.target) : null;
|
|
385
|
+
if (el) {
|
|
386
|
+
var r = el.getBoundingClientRect();
|
|
387
|
+
var sx = window.pageXOffset || 0, sy = window.pageYOffset || 0;
|
|
388
|
+
root.style.position = 'absolute';
|
|
389
|
+
root.style.left = (r.left + sx + r.width / 2) + 'px';
|
|
390
|
+
root.style.top = (r.top + sy + r.height / 2) + 'px';
|
|
391
|
+
} else {
|
|
392
|
+
if (cfg.target) console.warn('[meccha-chameleon] target not found:', cfg.target);
|
|
393
|
+
root.style.position = 'fixed';
|
|
394
|
+
root.style.left = '50%';
|
|
395
|
+
root.style.top = '50%';
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
_loop: function (gl, U) {
|
|
400
|
+
var self = this;
|
|
401
|
+
function frame(ts) {
|
|
402
|
+
var cfg = self._state;
|
|
403
|
+
if (!cfg) return;
|
|
404
|
+
var t = (ts || 0) / 1000;
|
|
405
|
+
var ang = cfg.animateLight ? t * cfg.lightSpeed * Math.PI * 2 : cfg.lightAngle;
|
|
406
|
+
gl.uniform3f(U.uLightPos, Math.cos(ang) * cfg.lightRadius, Math.sin(ang) * cfg.lightRadius, cfg.lightZ);
|
|
407
|
+
var lc = hexRgb(cfg.lightColor);
|
|
408
|
+
gl.uniform3f(U.uLightColor, lc[0], lc[1], lc[2]);
|
|
409
|
+
gl.uniform1f(U.uIntensity, cfg.lightIntensity);
|
|
410
|
+
gl.uniform1f(U.uAmbient, cfg.ambient);
|
|
411
|
+
gl.uniform1f(U.uSpecStrength, cfg.specularStrength);
|
|
412
|
+
gl.uniform1f(U.uShininess, cfg.specularHardness);
|
|
413
|
+
gl.uniform1f(U.uRelief, cfg.relief);
|
|
414
|
+
if (cfg.tint) { var tn = hexRgb(cfg.tint); gl.uniform3f(U.uTint, tn[0], tn[1], tn[2]); gl.uniform1f(U.uUseTint, 1); }
|
|
415
|
+
else gl.uniform1f(U.uUseTint, 0);
|
|
416
|
+
|
|
417
|
+
gl.clearColor(0, 0, 0, 0);
|
|
418
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
419
|
+
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
420
|
+
self._raf = requestAnimationFrame(frame);
|
|
421
|
+
}
|
|
422
|
+
this._raf = requestAnimationFrame(frame);
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
// Change parameters LIVE: MecchaChameleon.update({ lightIntensity: 2 })
|
|
426
|
+
update: function (options) {
|
|
427
|
+
if (this._state && options) {
|
|
428
|
+
Object.assign(this._state, options);
|
|
429
|
+
if (this._canvas) {
|
|
430
|
+
if ('opacity' in options) this._canvas.style.opacity = String(options.opacity);
|
|
431
|
+
if ('blendMode' in options) this._canvas.style.mixBlendMode = options.blendMode;
|
|
432
|
+
}
|
|
433
|
+
if (this._shadow) {
|
|
434
|
+
if ('shadowOpacity' in options) this._shadow.style.opacity = String(options.shadowOpacity);
|
|
435
|
+
if ('shadowBlendMode' in options) this._shadow.style.mixBlendMode = options.shadowBlendMode;
|
|
436
|
+
}
|
|
437
|
+
// Re-anchor when the target selector changes.
|
|
438
|
+
if ('target' in options) this._place();
|
|
439
|
+
}
|
|
440
|
+
return this;
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
unmount: function () {
|
|
444
|
+
if (this._raf) cancelAnimationFrame(this._raf);
|
|
445
|
+
if (this._onReposition) {
|
|
446
|
+
window.removeEventListener('resize', this._onReposition);
|
|
447
|
+
window.removeEventListener('scroll', this._onReposition, { passive: true, capture: true });
|
|
448
|
+
}
|
|
449
|
+
if (this._ro) this._ro.disconnect();
|
|
450
|
+
if (this._root && this._root.parentNode) this._root.parentNode.removeChild(this._root);
|
|
451
|
+
if (this._svg && this._svg.parentNode) this._svg.parentNode.removeChild(this._svg);
|
|
452
|
+
this._raf = this._canvas = this._root = this._svg = this._shadow = this._state = null;
|
|
453
|
+
this._ro = this._onReposition = null;
|
|
454
|
+
return this;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
return MecchaChameleon;
|
|
459
|
+
});
|