Per-zone effects in projection mapping
New 'Zona Effetto': each zone runs its own preset on an independent offscreen Visualizer (960x540, own WebGL context), stepped with the shared clock and audio so every panel dances to the same music with a different effect (fluid presets included; interactive/3D excluded). Zone row gains family/preset selectors; engines are created/destroyed as zones change and their canvases uploaded as zone textures each frame. Guide updated with the performance note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3d91806581
commit
8b4b2cbfa8
5 files changed
+102
-9
No files matched your search
+5
-2
@@ -213,6 +213,7 @@
|
|||||||
<div id="map-zones"></div>
|
<div id="map-zones"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="map-add-vis">➕ Zona Visual</button>
|
<button id="map-add-vis">➕ Zona Visual</button>
|
||||||
|
<button id="map-add-fx">➕ Zona Effetto</button>
|
||||||
<button id="map-add-img">➕ Zona Immagine…</button>
|
<button id="map-add-img">➕ Zona Immagine…</button>
|
||||||
<input type="file" id="map-img-input" accept="image/*" hidden />
|
<input type="file" id="map-img-input" accept="image/*" hidden />
|
||||||
</div>
|
</div>
|
||||||
@@ -224,8 +225,10 @@
|
|||||||
oggetti e superfici inclinate. Fuori dalle zone: nero.</p>
|
oggetti e superfici inclinate. Fuori dalle zone: nero.</p>
|
||||||
<p><b>1. Attiva</b> — l'output passa in modalità mappatura.</p>
|
<p><b>1. Attiva</b> — l'output passa in modalità mappatura.</p>
|
||||||
<p><b>2. Crea le zone</b> — <b>➕ Zona Visual</b> mostra l'effetto live in corso
|
<p><b>2. Crea le zone</b> — <b>➕ Zona Visual</b> mostra l'effetto live in corso
|
||||||
(qualsiasi famiglia, anche il ballerino 3D); <b>➕ Zona Immagine</b> incolla
|
(qualsiasi famiglia, anche il ballerino 3D); <b>➕ Zona Effetto</b> fa girare
|
||||||
un'immagine fissa su una superficie (corner-pin).</p>
|
nella zona un <b>preset indipendente</b> (ogni zona il suo effetto, tutti a tempo
|
||||||
|
con la stessa musica — ma ogni zona costa un rendering in più); <b>➕ Zona
|
||||||
|
Immagine</b> incolla un'immagine fissa su una superficie (corner-pin).</p>
|
||||||
<p><b>3. Posiziona</b> — spunta <b>Modifica zone</b>: sull'output compaiono le
|
<p><b>3. Posiziona</b> — spunta <b>Modifica zone</b>: sull'output compaiono le
|
||||||
maniglie. Trascina i <b>4 angoli</b> per adattare la zona alla superficie reale
|
maniglie. Trascina i <b>4 angoli</b> per adattare la zona alla superficie reale
|
||||||
(guardando la proiezione!), oppure clicca <b>dentro</b> la zona per spostarla
|
(guardando la proiezione!), oppure clicca <b>dentro</b> la zona per spostarla
|
||||||
|
|||||||
+45
-3
@@ -1440,11 +1440,30 @@ function renderMapZones() {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'map-zone';
|
row.className = 'map-zone';
|
||||||
const isImg = z.src && z.src.type === 'image';
|
const isImg = z.src && z.src.type === 'image';
|
||||||
|
const isFx = z.src && z.src.type === 'effect';
|
||||||
|
let mid;
|
||||||
|
if (isImg) {
|
||||||
|
mid = '<span class="mz-src" title="' + (z.src.path || '') + '">' +
|
||||||
|
String(z.src.path || '').split(/[\\/]/).pop() + '</span>';
|
||||||
|
} else if (isFx) {
|
||||||
|
const cur = EFFECTS.list[z.src.effectIndex] || EFFECTS.defaults();
|
||||||
|
let famOpts = '', preOpts = '';
|
||||||
|
EFFECTS.families.forEach((name, fi) => {
|
||||||
|
if (/^Interattivo|^Modello 3D/.test(name)) return;
|
||||||
|
famOpts += '<option value="' + fi + '"' + (fi === cur.family ? ' selected' : '') + '>' + name + '</option>';
|
||||||
|
});
|
||||||
|
EFFECTS.list.forEach((e2, ei) => {
|
||||||
|
if (e2.family !== cur.family) return;
|
||||||
|
preOpts += '<option value="' + ei + '"' + (ei === z.src.effectIndex ? ' selected' : '') + '>' +
|
||||||
|
e2.name.replace(EFFECTS.families[e2.family] + ' · ', '') + '</option>';
|
||||||
|
});
|
||||||
|
mid = '<span class="mz-fx"><select class="mz-fam">' + famOpts + '</select>' +
|
||||||
|
'<select class="mz-pre">' + preOpts + '</select></span>';
|
||||||
|
}
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<span class="mz-ico">' + (isImg ? '🖼' : '🌀') + '</span>' +
|
'<span class="mz-ico">' + (isImg ? '🖼' : (isFx ? '✨' : '🌀')) + '</span>' +
|
||||||
'<input class="mz-name" type="text" value="' + (z.name || 'Zona') + '" />' +
|
'<input class="mz-name" type="text" value="' + (z.name || 'Zona') + '" />' +
|
||||||
(isImg ? '<span class="mz-src" title="' + (z.src.path || '') + '">' +
|
(mid !== undefined ? mid
|
||||||
String(z.src.path || '').split(/[\\/]/).pop() + '</span>'
|
|
||||||
: '<span class="mz-rect">Porzione ' +
|
: '<span class="mz-rect">Porzione ' +
|
||||||
'<input class="mz-r" data-k="0" type="number" min="0" max="100" value="' + Math.round(z.srcRect[0]*100) + '" />%,' +
|
'<input class="mz-r" data-k="0" type="number" min="0" max="100" value="' + Math.round(z.srcRect[0]*100) + '" />%,' +
|
||||||
'<input class="mz-r" data-k="1" type="number" min="0" max="100" value="' + Math.round(z.srcRect[1]*100) + '" />% ' +
|
'<input class="mz-r" data-k="1" type="number" min="0" max="100" value="' + Math.round(z.srcRect[1]*100) + '" />% ' +
|
||||||
@@ -1455,6 +1474,27 @@ function renderMapZones() {
|
|||||||
row.querySelector('.mz-name').addEventListener('change', (e) => {
|
row.querySelector('.mz-name').addEventListener('change', (e) => {
|
||||||
z.name = e.target.value; mapSave(); mapSendZones();
|
z.name = e.target.value; mapSave(); mapSendZones();
|
||||||
});
|
});
|
||||||
|
const famSel = row.querySelector('.mz-fam');
|
||||||
|
if (famSel) {
|
||||||
|
const preSel = row.querySelector('.mz-pre');
|
||||||
|
famSel.addEventListener('change', () => {
|
||||||
|
const fam = parseInt(famSel.value, 10);
|
||||||
|
preSel.innerHTML = '';
|
||||||
|
EFFECTS.list.forEach((e2, ei) => {
|
||||||
|
if (e2.family !== fam) return;
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = ei;
|
||||||
|
o.textContent = e2.name.replace(EFFECTS.families[fam] + ' · ', '');
|
||||||
|
preSel.appendChild(o);
|
||||||
|
});
|
||||||
|
z.src.effectIndex = parseInt(preSel.value, 10);
|
||||||
|
mapSave(); mapSendZones();
|
||||||
|
});
|
||||||
|
preSel.addEventListener('change', () => {
|
||||||
|
z.src.effectIndex = parseInt(preSel.value, 10);
|
||||||
|
mapSave(); mapSendZones();
|
||||||
|
});
|
||||||
|
}
|
||||||
row.querySelectorAll('.mz-r').forEach(inp => inp.addEventListener('change', (e) => {
|
row.querySelectorAll('.mz-r').forEach(inp => inp.addEventListener('change', (e) => {
|
||||||
const k = parseInt(e.target.dataset.k, 10);
|
const k = parseInt(e.target.dataset.k, 10);
|
||||||
z.srcRect[k] = Math.max(0, Math.min(1, (parseInt(e.target.value, 10) || 0)/100));
|
z.srcRect[k] = Math.max(0, Math.min(1, (parseInt(e.target.value, 10) || 0)/100));
|
||||||
@@ -1477,6 +1517,8 @@ $('#map-on').addEventListener('change', (e) => {
|
|||||||
});
|
});
|
||||||
$('#map-edit').addEventListener('change', (e) => send({ type: 'mapEdit', on: e.target.checked }));
|
$('#map-edit').addEventListener('change', (e) => send({ type: 'mapEdit', on: e.target.checked }));
|
||||||
$('#map-add-vis').addEventListener('click', () => mapAddZone({ type: 'visual' }));
|
$('#map-add-vis').addEventListener('click', () => mapAddZone({ type: 'visual' }));
|
||||||
|
$('#map-add-fx').addEventListener('click', () =>
|
||||||
|
mapAddZone({ type: 'effect', effectIndex: EFFECTS.list.indexOf(EFFECTS.defaults()) < 0 ? 12 : EFFECTS.list.indexOf(EFFECTS.defaults()) }));
|
||||||
$('#map-add-img').addEventListener('click', () => $('#map-img-input').click());
|
$('#map-add-img').addEventListener('click', () => $('#map-img-input').click());
|
||||||
$('#map-img-input').addEventListener('change', (e) => {
|
$('#map-img-input').addEventListener('change', (e) => {
|
||||||
const f = e.target.files[0];
|
const f = e.target.files[0];
|
||||||
|
|||||||
+43
-1
@@ -104,6 +104,7 @@ class MappingSim {
|
|||||||
this.visTex = mkTex();
|
this.visTex = mkTex();
|
||||||
this._mkTex = mkTex;
|
this._mkTex = mkTex;
|
||||||
this.imgTex = {}; // path -> {tex, ok}
|
this.imgTex = {}; // path -> {tex, ok}
|
||||||
|
this.fx = {}; // zoneId -> {canvas, viz, tex, effectIndex} own engine
|
||||||
this.zones = [];
|
this.zones = [];
|
||||||
this.editOn = false;
|
this.editOn = false;
|
||||||
this.selected = -1;
|
this.selected = -1;
|
||||||
@@ -121,6 +122,33 @@ class MappingSim {
|
|||||||
opacity: z.opacity !== undefined ? z.opacity : 1
|
opacity: z.opacity !== undefined ? z.opacity : 1
|
||||||
}));
|
}));
|
||||||
if (this.selected >= this.zones.length) this.selected = this.zones.length - 1;
|
if (this.selected >= this.zones.length) this.selected = this.zones.length - 1;
|
||||||
|
// per-zone effect engines: an independent Visualizer on an offscreen
|
||||||
|
// canvas, so every zone can run a DIFFERENT preset in sync with the music
|
||||||
|
for (const id in this.fx) {
|
||||||
|
if (!this.zones.some(z => z.src.type === 'effect' && String(z.id) === id)) {
|
||||||
|
try { this.fx[id].viz = null; } catch (e) {}
|
||||||
|
delete this.fx[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const z of this.zones) {
|
||||||
|
if (z.src.type !== 'effect' || !window.Visualizer || !window.EFFECTS) continue;
|
||||||
|
let f = this.fx[z.id];
|
||||||
|
if (!f) {
|
||||||
|
const cv = document.createElement('canvas');
|
||||||
|
cv.width = 960; cv.height = 540;
|
||||||
|
const viz = new window.Visualizer(cv);
|
||||||
|
viz.resize = () => {}; // fixed offscreen resolution
|
||||||
|
cv.width = 960; cv.height = 540;
|
||||||
|
viz.gl.viewport(0, 0, 960, 540);
|
||||||
|
f = this.fx[z.id] = { canvas: cv, viz, tex: this._mkTex(), effectIndex: -1 };
|
||||||
|
}
|
||||||
|
if (f.effectIndex !== z.src.effectIndex) {
|
||||||
|
let e = window.EFFECTS.list[z.src.effectIndex];
|
||||||
|
if (!e || e.isInteractive || e.isModel3d) e = window.EFFECTS.defaults();
|
||||||
|
f.viz.setEffect(e);
|
||||||
|
f.effectIndex = z.src.effectIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
// preload image textures
|
// preload image textures
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
for (const z of this.zones) {
|
for (const z of this.zones) {
|
||||||
@@ -193,10 +221,20 @@ class MappingSim {
|
|||||||
return hit;
|
return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render(timeSec, audio) {
|
||||||
const gl = this.gl, cv = this.canvas;
|
const gl = this.gl, cv = this.canvas;
|
||||||
const w = this.main.width, h = this.main.height;
|
const w = this.main.width, h = this.main.height;
|
||||||
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||||||
|
|
||||||
|
// step the per-zone effect engines and refresh their textures
|
||||||
|
for (const z of this.zones) {
|
||||||
|
const f = z.src.type === 'effect' && this.fx[z.id];
|
||||||
|
if (!f) continue;
|
||||||
|
try { f.viz.render(timeSec || 0, audio || {}); } catch (e) { continue; }
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, f.tex);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, f.canvas);
|
||||||
|
}
|
||||||
|
|
||||||
gl.viewport(0, 0, w, h);
|
gl.viewport(0, 0, w, h);
|
||||||
gl.clearColor(0, 0, 0, 1);
|
gl.clearColor(0, 0, 0, 1);
|
||||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
@@ -219,6 +257,10 @@ class MappingSim {
|
|||||||
const e = this.imgTex[z.src.url];
|
const e = this.imgTex[z.src.url];
|
||||||
if (!e || !e.ok) continue;
|
if (!e || !e.ok) continue;
|
||||||
tex = e.tex;
|
tex = e.tex;
|
||||||
|
} else if (z.src.type === 'effect') {
|
||||||
|
const f = this.fx[z.id];
|
||||||
|
if (!f) continue;
|
||||||
|
tex = f.tex;
|
||||||
}
|
}
|
||||||
const c = z.corners;
|
const c = z.corners;
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||||
|
|||||||
+7
-3
@@ -333,7 +333,9 @@ djv.onControl(async (m) => {
|
|||||||
ensureMapping().setZones((m.zones || []).map(z => Object.assign({}, z,
|
ensureMapping().setZones((m.zones || []).map(z => Object.assign({}, z,
|
||||||
{ src: z.src && z.src.type === 'image'
|
{ src: z.src && z.src.type === 'image'
|
||||||
? { type: 'image', url: toFileURL(z.src.path), path: z.src.path }
|
? { type: 'image', url: toFileURL(z.src.path), path: z.src.path }
|
||||||
: { type: 'visual' } })));
|
: (z.src && z.src.type === 'effect'
|
||||||
|
? { type: 'effect', effectIndex: z.src.effectIndex }
|
||||||
|
: { type: 'visual' }) })));
|
||||||
} catch (e) { djv.report({ type: 'error', message: 'Mappatura: ' + e.message }); }
|
} catch (e) { djv.report({ type: 'error', message: 'Mappatura: ' + e.message }); }
|
||||||
break;
|
break;
|
||||||
case 'mapOn':
|
case 'mapOn':
|
||||||
@@ -594,7 +596,7 @@ function frame() {
|
|||||||
const a = audio.values;
|
const a = audio.values;
|
||||||
avTick(performance.now());
|
avTick(performance.now());
|
||||||
viz.render(t * speed, a);
|
viz.render(t * speed, a);
|
||||||
if (mapOn && mapping) mapping.render();
|
if (mapOn && mapping) mapping.render(t * speed, a);
|
||||||
|
|
||||||
// Interactive family: tell the control panel if the camera didn't start
|
// Interactive family: tell the control panel if the camera didn't start
|
||||||
// (permission denied / missing device). Mouse interaction still works.
|
// (permission denied / missing device). Mouse interaction still works.
|
||||||
@@ -661,7 +663,9 @@ function ensureMapping() {
|
|||||||
// corner drags on the output flow back to the panel, which persists them
|
// corner drags on the output flow back to the panel, which persists them
|
||||||
mapping.onChange = (zones) => djv.report({ type: 'mapZones',
|
mapping.onChange = (zones) => djv.report({ type: 'mapZones',
|
||||||
zones: zones.map(z => ({ id: z.id, name: z.name,
|
zones: zones.map(z => ({ id: z.id, name: z.name,
|
||||||
src: z.src.type === 'image' ? { type: 'image', path: z.src.path } : { type: 'visual' },
|
src: z.src.type === 'image' ? { type: 'image', path: z.src.path }
|
||||||
|
: (z.src.type === 'effect' ? { type: 'effect', effectIndex: z.src.effectIndex }
|
||||||
|
: { type: 'visual' }),
|
||||||
corners: z.corners, srcRect: z.srcRect, opacity: z.opacity })) });
|
corners: z.corners, srcRect: z.srcRect, opacity: z.opacity })) });
|
||||||
}
|
}
|
||||||
return mapping;
|
return mapping;
|
||||||
|
|||||||
@@ -931,6 +931,8 @@ kbd {
|
|||||||
.map-zone .mz-name { width: 90px; flex: 0 0 auto; }
|
.map-zone .mz-name { width: 90px; flex: 0 0 auto; }
|
||||||
.map-zone .mz-src { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.75; }
|
.map-zone .mz-src { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.75; }
|
||||||
.map-zone .mz-rect { flex: 1; display: flex; align-items: center; gap: 2px; white-space: nowrap; opacity: 0.9; }
|
.map-zone .mz-rect { flex: 1; display: flex; align-items: center; gap: 2px; white-space: nowrap; opacity: 0.9; }
|
||||||
|
.map-zone .mz-fx { flex: 1; display: flex; gap: 4px; min-width: 0; }
|
||||||
|
.map-zone .mz-fx select { flex: 1; min-width: 0; padding: 4px 6px; font-size: 11px; }
|
||||||
.map-zone .mz-r { width: 44px; flex: 0 0 auto; }
|
.map-zone .mz-r { width: 44px; flex: 0 0 auto; }
|
||||||
.map-zone .mz-op { flex: 0 0 70px; }
|
.map-zone .mz-op { flex: 0 0 70px; }
|
||||||
.map-zone .mz-del {
|
.map-zone .mz-del {
|
||||||
|
|||||||
Reference in new issue
Block a user