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