From 8b4b2cbfa815c1b2569dd4179b5c26c507b25f0e Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 31 Jul 2026 17:09:20 +0200 Subject: [PATCH] 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 --- src/control.html | 7 +++++-- src/control.js | 48 +++++++++++++++++++++++++++++++++++++++++++++--- src/mapping.js | 44 +++++++++++++++++++++++++++++++++++++++++++- src/output.js | 10 +++++++--- src/style.css | 2 ++ 5 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/control.html b/src/control.html index ea9de0a..e34702d 100644 --- a/src/control.html +++ b/src/control.html @@ -213,6 +213,7 @@
+
@@ -224,8 +225,10 @@ oggetti e superfici inclinate. Fuori dalle zone: nero.

1. Attiva — l'output passa in modalità mappatura.

2. Crea le zone — ➕ Zona Visual mostra l'effetto live in corso - (qualsiasi famiglia, anche il ballerino 3D); ➕ Zona Immagine incolla - un'immagine fissa su una superficie (corner-pin).

+ (qualsiasi famiglia, anche il ballerino 3D); ➕ Zona Effetto fa girare + nella zona un preset indipendente (ogni zona il suo effetto, tutti a tempo + con la stessa musica — ma ogni zona costa un rendering in più); ➕ Zona + Immagine incolla un'immagine fissa su una superficie (corner-pin).

3. Posiziona — spunta Modifica zone: sull'output compaiono le maniglie. Trascina i 4 angoli per adattare la zona alla superficie reale (guardando la proiezione!), oppure clicca dentro la zona per spostarla diff --git a/src/control.js b/src/control.js index 9b4edb0..501de6d 100644 --- a/src/control.js +++ b/src/control.js @@ -1440,11 +1440,30 @@ function renderMapZones() { const row = document.createElement('div'); row.className = 'map-zone'; const isImg = z.src && z.src.type === 'image'; + const isFx = z.src && z.src.type === 'effect'; + let mid; + if (isImg) { + mid = '' + + String(z.src.path || '').split(/[\\/]/).pop() + ''; + } 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 += ''; + }); + EFFECTS.list.forEach((e2, ei) => { + if (e2.family !== cur.family) return; + preOpts += ''; + }); + mid = '' + + ''; + } row.innerHTML = - '' + (isImg ? '🖼' : '🌀') + '' + + '' + (isImg ? '🖼' : (isFx ? '✨' : '🌀')) + '' + '' + - (isImg ? '' + - String(z.src.path || '').split(/[\\/]/).pop() + '' + (mid !== undefined ? mid : 'Porzione ' + '%,' + '% ' + @@ -1455,6 +1474,27 @@ function renderMapZones() { row.querySelector('.mz-name').addEventListener('change', (e) => { 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) => { const k = parseInt(e.target.dataset.k, 10); 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-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-img-input').addEventListener('change', (e) => { const f = e.target.files[0]; diff --git a/src/mapping.js b/src/mapping.js index 9711615..57f0d81 100644 --- a/src/mapping.js +++ b/src/mapping.js @@ -104,6 +104,7 @@ class MappingSim { this.visTex = mkTex(); this._mkTex = mkTex; this.imgTex = {}; // path -> {tex, ok} + this.fx = {}; // zoneId -> {canvas, viz, tex, effectIndex} own engine this.zones = []; this.editOn = false; this.selected = -1; @@ -121,6 +122,33 @@ class MappingSim { opacity: z.opacity !== undefined ? z.opacity : 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 const gl = this.gl; for (const z of this.zones) { @@ -193,10 +221,20 @@ class MappingSim { return hit; } - render() { + render(timeSec, audio) { const gl = this.gl, cv = this.canvas; const w = this.main.width, h = this.main.height; 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.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); @@ -219,6 +257,10 @@ class MappingSim { const e = this.imgTex[z.src.url]; if (!e || !e.ok) continue; 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; gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ diff --git a/src/output.js b/src/output.js index 1d9cd44..1be5992 100644 --- a/src/output.js +++ b/src/output.js @@ -333,7 +333,9 @@ djv.onControl(async (m) => { ensureMapping().setZones((m.zones || []).map(z => Object.assign({}, z, { src: z.src && z.src.type === 'image' ? { 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 }); } break; case 'mapOn': @@ -594,7 +596,7 @@ function frame() { const a = audio.values; avTick(performance.now()); 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 // (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 mapping.onChange = (zones) => djv.report({ type: 'mapZones', 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 })) }); } return mapping; diff --git a/src/style.css b/src/style.css index cef2c51..22fbb63 100644 --- a/src/style.css +++ b/src/style.css @@ -931,6 +931,8 @@ kbd { .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-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-op { flex: 0 0 70px; } .map-zone .mz-del {