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
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MecchaChameleon — WebGL engine (native ESM build)
|
|
3
|
+
* Drops a figure onto the page as a visual overlay and lights it in 3D from a
|
|
4
|
+
* normal map (relief, diffuse + specular). Optional brush-painted background
|
|
5
|
+
* and shadow texture. Non-interactive (visual only).
|
|
6
|
+
*
|
|
7
|
+
* This is the ESM entry point (`import`). It has NO top-level side effects, so
|
|
8
|
+
* with "sideEffects": false in package.json bundlers can tree-shake it away
|
|
9
|
+
* when unused. The CommonJS/UMD build (for `require` and <script> CDN usage)
|
|
10
|
+
* lives in ./meccha-chameleon.js and is kept in sync with this file.
|
|
11
|
+
*
|
|
12
|
+
* Browser-only: mount() touches the DOM, so call it on the client.
|
|
13
|
+
*
|
|
14
|
+
* import MecchaChameleon from 'meccha-chameleon';
|
|
15
|
+
* MecchaChameleon.mount({ image:'figure.png', normalMap:'figure-normal.png', target:'#hero' });
|
|
16
|
+
*
|
|
17
|
+
* NOTE: WebGL cannot load textures from file://. Serve the page over HTTP.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DEFAULTS = {
|
|
21
|
+
image: null, // (REQUIRED) base color PNG (with alpha)
|
|
22
|
+
normalMap: null, // (REQUIRED) normal map aligned to the figure
|
|
23
|
+
|
|
24
|
+
target: null, // CSS selector (#id / .class) the figure is anchored to
|
|
25
|
+
// (centered over that element). Falls back to the
|
|
26
|
+
// viewport center when null or the element is missing.
|
|
27
|
+
width: 200, // width in px (height keeps the image aspect ratio)
|
|
28
|
+
|
|
29
|
+
// --- Light ---
|
|
30
|
+
animateLight: false, // the light orbits on its own
|
|
31
|
+
lightSpeed: 0.35, // turns per second
|
|
32
|
+
lightRadius: 0.35, // orbit radius (0..~1.5)
|
|
33
|
+
lightAngle: -0.7, // fixed angle (rad) when animateLight = false
|
|
34
|
+
lightZ: 1.4, // light height/closeness (lower = grazing = more relief)
|
|
35
|
+
lightColor: '#ffffff',
|
|
36
|
+
lightIntensity: 0.45,
|
|
37
|
+
|
|
38
|
+
ambient: 0.22, // fill light (0 = black shadows)
|
|
39
|
+
specularStrength: 0.75,// highlight strength
|
|
40
|
+
specularHardness: 22, // highlight size (higher = smaller spot)
|
|
41
|
+
|
|
42
|
+
relief: 1, // exaggerates the normal map (1 = as-is)
|
|
43
|
+
tint: null, // hex color multiplied over the base color (e.g. '#8fd0ff')
|
|
44
|
+
|
|
45
|
+
// --- Brush-painted background (CSS layer beneath the figure) ---
|
|
46
|
+
brush: true, // paints the real background inside the silhouette
|
|
47
|
+
brushUsesNormalMap: false, // true = rigid refraction following the shape (glass);
|
|
48
|
+
// false = noise smear (brush strokes, more "painted")
|
|
49
|
+
brushStrength: 24, // smear/displacement in px (the stroke drags the background)
|
|
50
|
+
brushFrequency: 0.03, // stroke size: lower = thicker stroke
|
|
51
|
+
brushOctaves: 2,
|
|
52
|
+
brushBlur: 1.0, // blur in px -> wet-paint look
|
|
53
|
+
brushPosterize: 17, // tones per channel (flattens color into patches). 0 = off
|
|
54
|
+
brushGrain: 0.1, // canvas/bristle texture (0 = smooth, 1 = strong)
|
|
55
|
+
brushSaturation: 1.25,
|
|
56
|
+
brushContrast: 1.08,
|
|
57
|
+
|
|
58
|
+
// --- Figure layer ---
|
|
59
|
+
opacity: 0.2, // lower (e.g. 0.5) to see the painted background through the figure
|
|
60
|
+
blendMode: 'normal', // canvas mix-blend-mode ('multiply', 'screen'...)
|
|
61
|
+
|
|
62
|
+
// --- Shadow layer (PNG texture on top of everything) ---
|
|
63
|
+
shadow: null, // shadow PNG URL (transparent, aligned to the figure)
|
|
64
|
+
shadowOpacity: 0.3, // individual shadow opacity
|
|
65
|
+
shadowBlendMode: 'normal', // shadow mix-blend-mode
|
|
66
|
+
|
|
67
|
+
zIndex: 2147483000,
|
|
68
|
+
id: 'meccha-chameleon-overlay'
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const VERT =
|
|
72
|
+
'attribute vec2 aPos;varying vec2 vUv;' +
|
|
73
|
+
'void main(){vUv=vec2(aPos.x*0.5+0.5,1.0-(aPos.y*0.5+0.5));' +
|
|
74
|
+
'gl_Position=vec4(aPos,0.0,1.0);}';
|
|
75
|
+
|
|
76
|
+
const FRAG =
|
|
77
|
+
'precision mediump float;varying vec2 vUv;' +
|
|
78
|
+
'uniform sampler2D uAlbedo;uniform sampler2D uNormal;' +
|
|
79
|
+
'uniform vec3 uLightPos;uniform vec3 uLightColor;' +
|
|
80
|
+
'uniform float uIntensity;uniform float uAmbient;' +
|
|
81
|
+
'uniform float uSpecStrength;uniform float uShininess;' +
|
|
82
|
+
'uniform float uRelief;uniform vec3 uTint;uniform float uUseTint;' +
|
|
83
|
+
'void main(){' +
|
|
84
|
+
' vec4 albedo=texture2D(uAlbedo,vUv);' +
|
|
85
|
+
' if(albedo.a<0.01) discard;' +
|
|
86
|
+
' vec3 nTex=texture2D(uNormal,vUv).rgb*2.0-1.0;' +
|
|
87
|
+
' nTex.y=-nTex.y;' + // flip G to screen space (y goes down)
|
|
88
|
+
' nTex.xy*=uRelief;' +
|
|
89
|
+
' vec3 N=normalize(nTex);' +
|
|
90
|
+
' vec3 fragPos=vec3(vUv*2.0-1.0,0.0);' +
|
|
91
|
+
' vec3 L=normalize(uLightPos-fragPos);' +
|
|
92
|
+
' vec3 V=vec3(0.0,0.0,1.0);' +
|
|
93
|
+
' vec3 H=normalize(L+V);' +
|
|
94
|
+
' float diff=max(dot(N,L),0.0);' +
|
|
95
|
+
' float spec=pow(max(dot(N,H),0.0),uShininess)*uSpecStrength;' +
|
|
96
|
+
' vec3 base=albedo.rgb;' +
|
|
97
|
+
' if(uUseTint>0.5) base*=uTint;' +
|
|
98
|
+
' vec3 color=base*(uAmbient+diff*uLightColor*uIntensity)+spec*uLightColor;' +
|
|
99
|
+
' gl_FragColor=vec4(color,albedo.a);' +
|
|
100
|
+
'}';
|
|
101
|
+
|
|
102
|
+
function hexRgb(h) {
|
|
103
|
+
h = String(h).replace('#', '');
|
|
104
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
105
|
+
const n = parseInt(h, 16);
|
|
106
|
+
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Injects the SVG <filter> that PAINTS the real background (brush effect):
|
|
110
|
+
// noise smear -> blur -> posterize (color patches) -> canvas grain.
|
|
111
|
+
function createBrushFilter(cfg, uid) {
|
|
112
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
113
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
114
|
+
Object.assign(svg.style, { position: 'absolute', width: '0', height: '0', overflow: 'hidden' });
|
|
115
|
+
const f = document.createElementNS(NS, 'filter');
|
|
116
|
+
const fid = 'meccha-chameleon-brush-' + uid;
|
|
117
|
+
f.setAttribute('id', fid);
|
|
118
|
+
f.setAttribute('x', '-20%'); f.setAttribute('y', '-20%');
|
|
119
|
+
f.setAttribute('width', '140%'); f.setAttribute('height', '140%');
|
|
120
|
+
f.setAttribute('color-interpolation-filters', 'sRGB');
|
|
121
|
+
|
|
122
|
+
function el(tag, attrs) {
|
|
123
|
+
const e = document.createElementNS(NS, tag);
|
|
124
|
+
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
|
125
|
+
f.appendChild(e);
|
|
126
|
+
return e;
|
|
127
|
+
}
|
|
128
|
+
function transfer(inName, out, slope, intercept, discreteTable) {
|
|
129
|
+
const ct = el('feComponentTransfer', { in: inName, result: out });
|
|
130
|
+
['feFuncR', 'feFuncG', 'feFuncB'].forEach(function (fn) {
|
|
131
|
+
const fe = document.createElementNS(NS, fn);
|
|
132
|
+
if (discreteTable) { fe.setAttribute('type', 'discrete'); fe.setAttribute('tableValues', discreteTable); }
|
|
133
|
+
else { fe.setAttribute('type', 'linear'); fe.setAttribute('slope', slope); fe.setAttribute('intercept', intercept); }
|
|
134
|
+
ct.appendChild(fe);
|
|
135
|
+
});
|
|
136
|
+
return ct;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 1) Smear map: coarse noise (brush strokes) or normal map (rigid refraction).
|
|
140
|
+
if (cfg.brushUsesNormalMap && cfg.normalMap) {
|
|
141
|
+
const im = el('feImage', { x: '0', y: '0', width: '100%', height: '100%',
|
|
142
|
+
preserveAspectRatio: 'none', result: 'map' });
|
|
143
|
+
im.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', cfg.normalMap);
|
|
144
|
+
im.setAttribute('href', cfg.normalMap);
|
|
145
|
+
} else {
|
|
146
|
+
el('feTurbulence', { type: 'fractalNoise',
|
|
147
|
+
baseFrequency: cfg.brushFrequency + ' ' + cfg.brushFrequency,
|
|
148
|
+
numOctaves: cfg.brushOctaves, seed: '7', stitchTiles: 'stitch', result: 'map' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2) Displace the background (drags the texture like a brush stroke).
|
|
152
|
+
el('feDisplacementMap', { in: 'SourceGraphic', in2: 'map',
|
|
153
|
+
scale: cfg.brushStrength, xChannelSelector: 'R', yChannelSelector: 'G', result: 'smear' });
|
|
154
|
+
|
|
155
|
+
// 3) Blur to fuse into wet-paint strokes.
|
|
156
|
+
el('feGaussianBlur', { in: 'smear', stdDeviation: cfg.brushBlur, result: 'soft' });
|
|
157
|
+
|
|
158
|
+
let current = 'soft';
|
|
159
|
+
|
|
160
|
+
// 4) Posterize: flattens color into flat patches (the essence of "painted").
|
|
161
|
+
if (cfg.brushPosterize && cfg.brushPosterize > 1) {
|
|
162
|
+
const n = Math.round(cfg.brushPosterize), tv = [];
|
|
163
|
+
for (let i = 0; i < n; i++) tv.push((i / (n - 1)).toFixed(4));
|
|
164
|
+
transfer('soft', 'post', null, null, tv.join(' '));
|
|
165
|
+
current = 'post';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 5) Canvas/bristle grain: fine gray noise multiplied on top.
|
|
169
|
+
if (cfg.brushGrain && cfg.brushGrain > 0) {
|
|
170
|
+
el('feTurbulence', { type: 'fractalNoise', baseFrequency: '0.7 0.7',
|
|
171
|
+
numOctaves: '2', seed: '11', result: 'gn0' });
|
|
172
|
+
// Noise -> opaque gray (RGB = luminance, alpha = 1), then compress its range.
|
|
173
|
+
el('feColorMatrix', { in: 'gn0', type: 'matrix', result: 'gg',
|
|
174
|
+
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' });
|
|
175
|
+
const g = cfg.brushGrain;
|
|
176
|
+
transfer('gg', 'grain', String(g), String(1 - g)); // map [0,1] -> [1-g, 1] (mostly bright)
|
|
177
|
+
el('feBlend', { mode: 'multiply', in: current, in2: 'grain', result: 'painted' });
|
|
178
|
+
current = 'painted';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Final output (ensures the active result is the last primitive).
|
|
182
|
+
el('feOffset', { in: current, dx: '0', dy: '0' });
|
|
183
|
+
|
|
184
|
+
svg.appendChild(f);
|
|
185
|
+
document.body.appendChild(svg);
|
|
186
|
+
return { svg, fid };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function compileShader(gl, type, src) {
|
|
190
|
+
const s = gl.createShader(type);
|
|
191
|
+
gl.shaderSource(s, src);
|
|
192
|
+
gl.compileShader(s);
|
|
193
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
194
|
+
console.error('[meccha-chameleon] shader:', gl.getShaderInfoLog(s));
|
|
195
|
+
}
|
|
196
|
+
return s;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function loadTexture(gl, url, cb) {
|
|
200
|
+
const tex = gl.createTexture();
|
|
201
|
+
const img = new Image();
|
|
202
|
+
img.onload = function () {
|
|
203
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
204
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
205
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
206
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
207
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
208
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
209
|
+
cb(tex, img.naturalWidth, img.naturalHeight);
|
|
210
|
+
};
|
|
211
|
+
img.onerror = function () { console.error('[meccha-chameleon] failed to load', url); };
|
|
212
|
+
img.src = url;
|
|
213
|
+
return tex;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let COUNTER = 0;
|
|
217
|
+
|
|
218
|
+
const MecchaChameleon = {
|
|
219
|
+
_state: null,
|
|
220
|
+
_raf: null,
|
|
221
|
+
_canvas: null,
|
|
222
|
+
_root: null,
|
|
223
|
+
_svg: null,
|
|
224
|
+
_shadow: null,
|
|
225
|
+
_ro: null,
|
|
226
|
+
_onReposition: null,
|
|
227
|
+
|
|
228
|
+
mount(options) {
|
|
229
|
+
this.unmount();
|
|
230
|
+
const cfg = Object.assign({}, DEFAULTS, options || {});
|
|
231
|
+
if (!cfg.image || !cfg.normalMap) {
|
|
232
|
+
console.error('[meccha-chameleon] Missing "image" and/or "normalMap".');
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
this._state = cfg;
|
|
236
|
+
const self = this;
|
|
237
|
+
const start = () => self._init(cfg);
|
|
238
|
+
if (document.readyState === 'loading') {
|
|
239
|
+
document.addEventListener('DOMContentLoaded', start, { once: true });
|
|
240
|
+
} else { start(); }
|
|
241
|
+
return this;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
_init(cfg) {
|
|
245
|
+
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
|
|
246
|
+
const uid = ++COUNTER;
|
|
247
|
+
const self = this;
|
|
248
|
+
|
|
249
|
+
// Container above everything (non-interactive). Its left/top/position are
|
|
250
|
+
// set by _place(), which anchors it to cfg.target (or the viewport center).
|
|
251
|
+
const root = document.createElement('div');
|
|
252
|
+
root.id = cfg.id;
|
|
253
|
+
Object.assign(root.style, {
|
|
254
|
+
width: cfg.width + 'px',
|
|
255
|
+
transform: 'translate(-50%,-50%)',
|
|
256
|
+
zIndex: String(cfg.zIndex),
|
|
257
|
+
pointerEvents: 'none'
|
|
258
|
+
});
|
|
259
|
+
document.body.appendChild(root);
|
|
260
|
+
this._root = root;
|
|
261
|
+
|
|
262
|
+
// Anchor to the target selector now and keep it in sync as the page
|
|
263
|
+
// scrolls / reflows / resizes.
|
|
264
|
+
this._place();
|
|
265
|
+
this._onReposition = () => self._place();
|
|
266
|
+
window.addEventListener('resize', this._onReposition);
|
|
267
|
+
window.addEventListener('scroll', this._onReposition, { passive: true, capture: true });
|
|
268
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
269
|
+
this._ro = new ResizeObserver(this._onReposition);
|
|
270
|
+
this._ro.observe(document.documentElement);
|
|
271
|
+
const tgt = cfg.target && document.querySelector(cfg.target);
|
|
272
|
+
if (tgt) this._ro.observe(tgt);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Optional layer: the REAL background painted with the brush, clipped to the silhouette.
|
|
276
|
+
if (cfg.brush) {
|
|
277
|
+
const filter = createBrushFilter(cfg, uid);
|
|
278
|
+
this._svg = filter.svg;
|
|
279
|
+
const brushDiv = document.createElement('div');
|
|
280
|
+
const bf = 'url(#' + filter.fid + ') saturate(' + cfg.brushSaturation + ') contrast(' + cfg.brushContrast + ')';
|
|
281
|
+
const mask = 'url("' + cfg.image + '")';
|
|
282
|
+
Object.assign(brushDiv.style, {
|
|
283
|
+
position: 'absolute', inset: '0', pointerEvents: 'none',
|
|
284
|
+
WebkitBackdropFilter: bf, backdropFilter: bf,
|
|
285
|
+
WebkitMaskImage: mask, maskImage: mask,
|
|
286
|
+
WebkitMaskSize: '100% 100%', maskSize: '100% 100%',
|
|
287
|
+
WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat'
|
|
288
|
+
});
|
|
289
|
+
root.appendChild(brushDiv);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Lit figure layer (WebGL), above the brush.
|
|
293
|
+
const canvas = document.createElement('canvas');
|
|
294
|
+
Object.assign(canvas.style, {
|
|
295
|
+
position: 'relative', display: 'block',
|
|
296
|
+
width: '100%', height: 'auto',
|
|
297
|
+
pointerEvents: 'none',
|
|
298
|
+
opacity: String(cfg.opacity),
|
|
299
|
+
mixBlendMode: cfg.blendMode
|
|
300
|
+
});
|
|
301
|
+
root.appendChild(canvas);
|
|
302
|
+
this._canvas = canvas;
|
|
303
|
+
|
|
304
|
+
// Shadow layer (PNG) on top of everything, with its own opacity and blend mode.
|
|
305
|
+
if (cfg.shadow) {
|
|
306
|
+
const shadow = document.createElement('img');
|
|
307
|
+
shadow.src = cfg.shadow;
|
|
308
|
+
shadow.alt = '';
|
|
309
|
+
shadow.draggable = false;
|
|
310
|
+
Object.assign(shadow.style, {
|
|
311
|
+
position: 'absolute', left: '0', top: '0',
|
|
312
|
+
width: '100%', height: '100%',
|
|
313
|
+
pointerEvents: 'none', userSelect: 'none',
|
|
314
|
+
opacity: String(cfg.shadowOpacity),
|
|
315
|
+
mixBlendMode: cfg.shadowBlendMode
|
|
316
|
+
});
|
|
317
|
+
root.appendChild(shadow);
|
|
318
|
+
this._shadow = shadow;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, alpha: true })
|
|
322
|
+
|| canvas.getContext('experimental-webgl');
|
|
323
|
+
if (!gl) { console.error('[meccha-chameleon] WebGL not available'); return; }
|
|
324
|
+
|
|
325
|
+
const prog = gl.createProgram();
|
|
326
|
+
gl.attachShader(prog, compileShader(gl, gl.VERTEX_SHADER, VERT));
|
|
327
|
+
gl.attachShader(prog, compileShader(gl, gl.FRAGMENT_SHADER, FRAG));
|
|
328
|
+
gl.linkProgram(prog);
|
|
329
|
+
gl.useProgram(prog);
|
|
330
|
+
|
|
331
|
+
const buf = gl.createBuffer();
|
|
332
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
333
|
+
gl.bufferData(gl.ARRAY_BUFFER,
|
|
334
|
+
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
|
335
|
+
const aPos = gl.getAttribLocation(prog, 'aPos');
|
|
336
|
+
gl.enableVertexAttribArray(aPos);
|
|
337
|
+
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
|
338
|
+
|
|
339
|
+
gl.enable(gl.BLEND);
|
|
340
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
341
|
+
|
|
342
|
+
const U = {};
|
|
343
|
+
['uLightPos', 'uLightColor', 'uIntensity', 'uAmbient', 'uSpecStrength',
|
|
344
|
+
'uShininess', 'uRelief', 'uTint', 'uUseTint', 'uAlbedo', 'uNormal']
|
|
345
|
+
.forEach((n) => { U[n] = gl.getUniformLocation(prog, n); });
|
|
346
|
+
|
|
347
|
+
let ready = 0;
|
|
348
|
+
const texA = loadTexture(gl, cfg.image, function (t, w, h) {
|
|
349
|
+
// size the canvas to the image aspect ratio
|
|
350
|
+
const height = Math.round(cfg.width * (h / w));
|
|
351
|
+
canvas.style.height = height + 'px';
|
|
352
|
+
canvas.width = Math.round(cfg.width * dpr);
|
|
353
|
+
canvas.height = Math.round(height * dpr);
|
|
354
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
355
|
+
if (++ready === 2) start();
|
|
356
|
+
});
|
|
357
|
+
const texN = loadTexture(gl, cfg.normalMap, function () {
|
|
358
|
+
if (++ready === 2) start();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
function start() {
|
|
362
|
+
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texA);
|
|
363
|
+
gl.uniform1i(U.uAlbedo, 0);
|
|
364
|
+
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texN);
|
|
365
|
+
gl.uniform1i(U.uNormal, 1);
|
|
366
|
+
self._loop(gl, U);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
// Positions the root: centered over cfg.target, or the viewport center as
|
|
371
|
+
// a fallback. Uses absolute (document) coords so it scrolls with the target,
|
|
372
|
+
// and fixed coords for the viewport-center fallback.
|
|
373
|
+
_place() {
|
|
374
|
+
const cfg = this._state, root = this._root;
|
|
375
|
+
if (!cfg || !root) return;
|
|
376
|
+
const el = cfg.target ? document.querySelector(cfg.target) : null;
|
|
377
|
+
if (el) {
|
|
378
|
+
const r = el.getBoundingClientRect();
|
|
379
|
+
const sx = window.pageXOffset || 0, sy = window.pageYOffset || 0;
|
|
380
|
+
root.style.position = 'absolute';
|
|
381
|
+
root.style.left = (r.left + sx + r.width / 2) + 'px';
|
|
382
|
+
root.style.top = (r.top + sy + r.height / 2) + 'px';
|
|
383
|
+
} else {
|
|
384
|
+
if (cfg.target) console.warn('[meccha-chameleon] target not found:', cfg.target);
|
|
385
|
+
root.style.position = 'fixed';
|
|
386
|
+
root.style.left = '50%';
|
|
387
|
+
root.style.top = '50%';
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
_loop(gl, U) {
|
|
392
|
+
const self = this;
|
|
393
|
+
function frame(ts) {
|
|
394
|
+
const cfg = self._state;
|
|
395
|
+
if (!cfg) return;
|
|
396
|
+
const t = (ts || 0) / 1000;
|
|
397
|
+
const ang = cfg.animateLight ? t * cfg.lightSpeed * Math.PI * 2 : cfg.lightAngle;
|
|
398
|
+
gl.uniform3f(U.uLightPos, Math.cos(ang) * cfg.lightRadius, Math.sin(ang) * cfg.lightRadius, cfg.lightZ);
|
|
399
|
+
const lc = hexRgb(cfg.lightColor);
|
|
400
|
+
gl.uniform3f(U.uLightColor, lc[0], lc[1], lc[2]);
|
|
401
|
+
gl.uniform1f(U.uIntensity, cfg.lightIntensity);
|
|
402
|
+
gl.uniform1f(U.uAmbient, cfg.ambient);
|
|
403
|
+
gl.uniform1f(U.uSpecStrength, cfg.specularStrength);
|
|
404
|
+
gl.uniform1f(U.uShininess, cfg.specularHardness);
|
|
405
|
+
gl.uniform1f(U.uRelief, cfg.relief);
|
|
406
|
+
if (cfg.tint) { const tn = hexRgb(cfg.tint); gl.uniform3f(U.uTint, tn[0], tn[1], tn[2]); gl.uniform1f(U.uUseTint, 1); }
|
|
407
|
+
else gl.uniform1f(U.uUseTint, 0);
|
|
408
|
+
|
|
409
|
+
gl.clearColor(0, 0, 0, 0);
|
|
410
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
411
|
+
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
412
|
+
self._raf = requestAnimationFrame(frame);
|
|
413
|
+
}
|
|
414
|
+
this._raf = requestAnimationFrame(frame);
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
// Change parameters LIVE: MecchaChameleon.update({ lightIntensity: 2 })
|
|
418
|
+
update(options) {
|
|
419
|
+
if (this._state && options) {
|
|
420
|
+
Object.assign(this._state, options);
|
|
421
|
+
if (this._canvas) {
|
|
422
|
+
if ('opacity' in options) this._canvas.style.opacity = String(options.opacity);
|
|
423
|
+
if ('blendMode' in options) this._canvas.style.mixBlendMode = options.blendMode;
|
|
424
|
+
}
|
|
425
|
+
if (this._shadow) {
|
|
426
|
+
if ('shadowOpacity' in options) this._shadow.style.opacity = String(options.shadowOpacity);
|
|
427
|
+
if ('shadowBlendMode' in options) this._shadow.style.mixBlendMode = options.shadowBlendMode;
|
|
428
|
+
}
|
|
429
|
+
// Re-anchor when the target selector changes.
|
|
430
|
+
if ('target' in options) this._place();
|
|
431
|
+
}
|
|
432
|
+
return this;
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
unmount() {
|
|
436
|
+
if (this._raf) cancelAnimationFrame(this._raf);
|
|
437
|
+
if (this._onReposition) {
|
|
438
|
+
window.removeEventListener('resize', this._onReposition);
|
|
439
|
+
window.removeEventListener('scroll', this._onReposition, { passive: true, capture: true });
|
|
440
|
+
}
|
|
441
|
+
if (this._ro) this._ro.disconnect();
|
|
442
|
+
if (this._root && this._root.parentNode) this._root.parentNode.removeChild(this._root);
|
|
443
|
+
if (this._svg && this._svg.parentNode) this._svg.parentNode.removeChild(this._svg);
|
|
444
|
+
this._raf = this._canvas = this._root = this._svg = this._shadow = this._state = null;
|
|
445
|
+
this._ro = this._onReposition = null;
|
|
446
|
+
return this;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// Named exports (convenience, statically analyzable) + default export.
|
|
451
|
+
export const mount = (options) => MecchaChameleon.mount(options);
|
|
452
|
+
export const update = (options) => MecchaChameleon.update(options);
|
|
453
|
+
export const unmount = () => MecchaChameleon.unmount();
|
|
454
|
+
export { MecchaChameleon };
|
|
455
|
+
export default MecchaChameleon;
|