Modello 3D family: audio-reactive GLB model renderer

New model3d.js: minimal GLB loader (JSON+BIN chunks, accessors with
byteStride, node hierarchy baked into vertices, embedded PNG/JPEG base
colour textures) and a WebGL2 renderer on the shared canvas — palette
fill light, beat-pulsing fresnel rim, treble speculars, bass breathing
via normal displacement, orbiting camera auto-fitted to the bounding
box, gradient backdrop with a bass glow. A generated torus knot shows
before any GLB is loaded. 'Carica un modello .glb' button in the SVG
section; the path persists and is re-sent when the output restarts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-30 18:12:28 +02:00
1 parent b90066de6a
commit 65fb6ae895
7 files changed
+498 -2

No files matched your search

+4
View File
@@ -127,6 +127,10 @@
<button id="btn-svg-load" class="wide">📂 Carica un SVG/immagine…</button> <button id="btn-svg-load" class="wide">📂 Carica un SVG/immagine…</button>
<input type="file" id="svg-input" accept="image/svg+xml,image/*" hidden /> <input type="file" id="svg-input" accept="image/svg+xml,image/*" hidden />
<div class="hint-mini">Scegli una sagoma → diventa un effetto reattivo. Per cambiare colori/stile filtra la libreria su “SVG/Immagine”.</div> <div class="hint-mini">Scegli una sagoma → diventa un effetto reattivo. Per cambiare colori/stile filtra la libreria su “SVG/Immagine”.</div>
<label>Modello 3D (GLB)</label>
<button id="btn-glb-load" class="wide">🧊 Carica un modello .glb…</button>
<input type="file" id="glb-input" accept=".glb" hidden />
<div class="hint-mini" id="glb-name">Il modello ruota a tempo di musica nella famiglia “Modello 3D”.</div>
</section> </section>
<section class="span2" id="effect-seq-section"> <section class="span2" id="effect-seq-section">
+25
View File
@@ -143,6 +143,30 @@ $('#svg-input').addEventListener('change', (e) => {
e.target.value = ''; e.target.value = '';
}); });
// --- 3D model (GLB) for the 'Modello 3D' family -----------------------------
// The path persists and is re-sent when the output window (re)starts.
let glbPath = localStorage.getItem('glb3d') || '';
function glbSend() { if (glbPath) send({ type: 'glb', path: glbPath }); }
function glbLabel(name) {
$('#glb-name').textContent = name
? '🧊 ' + name + ' — attiva la famiglia “Modello 3D” per vederlo.'
: 'Il modello ruota a tempo di musica nella famiglia “Modello 3D”.';
}
if (glbPath) glbLabel(glbPath.split('/').pop());
$('#btn-glb-load').addEventListener('click', () => $('#glb-input').click());
$('#glb-input').addEventListener('change', (e) => {
const f = e.target.files[0];
if (!f) return;
const p = djv.pathForFile(f);
if (p) {
glbPath = p;
localStorage.setItem('glb3d', p);
glbSend();
glbLabel(f.name);
}
e.target.value = '';
});
// --- Effect sequence (playlist of effects with auto-cycle) --- // --- Effect sequence (playlist of effects with auto-cycle) ---
let sequence = []; // { effect, key } let sequence = []; // { effect, key }
let seqIndex = -1; let seqIndex = -1;
@@ -1495,6 +1519,7 @@ djv.onReport((m) => {
// The output reports its devices once at startup: good moment to push // The output reports its devices once at startup: good moment to push
// config the (possibly recreated) output window doesn't have yet. // config the (possibly recreated) output window doesn't have yet.
if (ledCfg.on) ledSend(); if (ledCfg.on) ledSend();
glbSend();
const sel = $('#device-select'); const sel = $('#device-select');
const cur = sel.value; const cur = sel.value;
sel.innerHTML = '<option value="">— Input live (mic/line/BlackHole) —</option>'; sel.innerHTML = '<option value="">— Input live (mic/line/BlackHole) —</option>';
+6 -2
View File
@@ -73,7 +73,10 @@ const FAMILIES = [
{ name: 'Onda Luminosa', scale: 1.0 }, { name: 'Onda Luminosa', scale: 1.0 },
{ name: 'Spettro Neon', scale: 1.0 }, { name: 'Spettro Neon', scale: 1.0 },
{ name: 'Onda 3D', scale: 1.0 }, { name: 'Onda 3D', scale: 1.0 },
{ name: 'Metallo Liquido', scale: 1.0 } { name: 'Metallo Liquido', scale: 1.0 },
// GLB model renderer (model3d.js), not the uber-shader: spins a loaded 3D
// model with palette lighting; a torus knot shows before any GLB is loaded.
{ name: 'Modello 3D', scale: 1.0, model3d: true }
]; ];
// Palettes: low colour (a) -> high colour (b), plus optional hue-cycle/sat. // Palettes: low colour (a) -> high colour (b), plus optional hue-cycle/sat.
@@ -134,7 +137,8 @@ function makeEffect(fi, pi, vi) {
isFluid: fam.fluid ? 1 : 0, isFluid: fam.fluid ? 1 : 0,
fluidMode: fam.fluidMode || 'ink', fluidMode: fam.fluidMode || 'ink',
isInteractive: fam.interactive ? 1 : 0, isInteractive: fam.interactive ? 1 : 0,
interactiveMode: fam.interMode || 'balls' interactiveMode: fam.interMode || 'balls',
isModel3d: fam.model3d ? 1 : 0
}; };
} }
+437
View File
@@ -0,0 +1,437 @@
// 3D model family: renders a GLB (glTF-binary) model audio-reactively on the
// shared WebGL2 canvas — same takeover pattern as FluidSim. Minimal loader
// (static meshes: POSITION/NORMAL/TEXCOORD_0 + baseColorTexture; node TRS is
// baked into the vertices at load). No external libraries. While no model is
// loaded a generated torus knot spins so the family works out of the box.
(function () {
// ------------------------------------------------------------ mat4 helpers
function m4mul(a, b) {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++)
o[c*4+r] = a[r]*b[c*4] + a[4+r]*b[c*4+1] + a[8+r]*b[c*4+2] + a[12+r]*b[c*4+3];
return o;
}
function m4persp(fov, asp, n, f) {
const t = 1/Math.tan(fov/2);
return new Float32Array([t/asp,0,0,0, 0,t,0,0, 0,0,(f+n)/(n-f),-1, 0,0,2*f*n/(n-f),0]);
}
function m4lookAt(eye, at) {
let zx = eye[0]-at[0], zy = eye[1]-at[1], zz = eye[2]-at[2];
const zl = Math.hypot(zx,zy,zz); zx/=zl; zy/=zl; zz/=zl;
let xx = zz, xz = -zx; // cross(up=(0,1,0), z)
const xl = Math.hypot(xx,xz) || 1; xx/=xl; xz/=xl;
const yx = zy*xz, yy = zz*xx - zx*xz, yz = -zy*xx; // cross(z, x)
return new Float32Array([
xx, yx, zx, 0, 0, yy, zy, 0, xz, yz, zz, 0,
-(xx*eye[0]+xz*eye[2]), -(yx*eye[0]+yy*eye[1]+yz*eye[2]), -(zx*eye[0]+zy*eye[1]+zz*eye[2]), 1]);
}
function m4rotY(a) {
const c = Math.cos(a), s = Math.sin(a);
return new Float32Array([c,0,-s,0, 0,1,0,0, s,0,c,0, 0,0,0,1]);
}
function m4scaleT(s, t) { // uniform scale then translate
return new Float32Array([s,0,0,0, 0,s,0,0, 0,0,s,0, t[0],t[1],t[2],1]);
}
// quaternion + TRS -> mat4 (column major)
function trsToMat(t, r, s) {
t = t || [0,0,0]; r = r || [0,0,0,1]; s = s || [1,1,1];
const [x,y,z,w] = r;
const m = new Float32Array([
(1-2*(y*y+z*z))*s[0], (2*(x*y+z*w))*s[0], (2*(x*z-y*w))*s[0], 0,
(2*(x*y-z*w))*s[1], (1-2*(x*x+z*z))*s[1], (2*(y*z+x*w))*s[1], 0,
(2*(x*z+y*w))*s[2], (2*(y*z-x*w))*s[2], (1-2*(x*x+y*y))*s[2], 0,
t[0], t[1], t[2], 1]);
return m;
}
// ------------------------------------------------------------ shaders
const MESH_VERT = `#version 300 es
in vec3 aPos; in vec3 aNorm; in vec2 aUV;
uniform mat4 uProj, uView, uModel;
uniform float uPulse;
out vec3 vN; out vec3 vW; out vec2 vUv;
void main(){
vec4 w = uModel * vec4(aPos + aNorm*uPulse, 1.0);
vW = w.xyz;
vN = mat3(uModel) * aNorm;
vUv = aUV;
gl_Position = uProj * uView * w;
}`;
const MESH_FRAG = `#version 300 es
precision highp float;
in vec3 vN; in vec3 vW; in vec2 vUv;
out vec4 frag;
uniform sampler2D uTex;
uniform int uHasTex;
uniform vec3 uBase, uColA, uColB, uCam;
uniform float uBeat, uLevel, uTreble;
void main(){
vec3 N = normalize(vN);
vec3 V = normalize(uCam - vW);
if (dot(N, V) < 0.0) N = -N; // light double-sided surfaces
vec3 L = normalize(vec3(0.5, 0.8, 0.6));
vec3 base = uHasTex == 1 ? texture(uTex, vUv).rgb : uBase;
float d = max(dot(N, L), 0.0);
vec3 col = base * (0.26 + 0.85*d);
col += uColA * 1.6 * max(dot(N, -L), 0.0) * 0.4; // palette fill light
float fr = pow(1.0 - max(dot(N, V), 0.0), 3.0);
col += uColB * fr * (0.45 + 0.95*uBeat + 0.4*uLevel); // rim pulses on beat
vec3 H = normalize(L + V);
col += vec3(1.0) * pow(max(dot(N, H), 0.0), 42.0) * (0.3 + 0.5*uTreble);
frag = vec4(col, 1.0);
}`;
// backdrop: dark palette gradient + a soft glow behind the model
const BG_VERT = `#version 300 es
in vec2 aPos; out vec2 vUv;
void main(){ vUv = aPos*0.5+0.5; gl_Position = vec4(aPos,0.,1.); }`;
const BG_FRAG = `#version 300 es
precision highp float;
in vec2 vUv; out vec4 frag;
uniform vec3 uColA, uColB;
uniform vec2 uRes;
uniform float uT, uBass, uBeat;
void main(){
float as = uRes.x/max(uRes.y,1.0);
vec2 p = vec2((vUv.x-0.5)*as, vUv.y-0.5);
vec3 col = uColA*0.16*(1.15 - vUv.y*0.9);
col += uColB * exp(-dot(p,p)*2.6) * (0.10 + 0.14*uBass + 0.08*uBeat);
// faint drifting halo bands for depth
col += uColA*0.35 * (0.5+0.5*sin(p.y*9.0 - uT*0.4)) * exp(-dot(p,p)*1.2) * 0.12;
frag = vec4(col, 1.0);
}`;
// ------------------------------------------------------------ GLB parsing
const CTYPE = { 5120: Int8Array, 5121: Uint8Array, 5122: Int16Array,
5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array };
const CSIZE = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 };
function readAccessor(json, bin, idx) {
const acc = json.accessors[idx];
const bv = json.bufferViews[acc.bufferView];
const Comp = CTYPE[acc.componentType];
const n = CSIZE[acc.type];
const stride = bv.byteStride || 0;
const base = (bv.byteOffset || 0) + (acc.byteOffset || 0);
const out = new Comp(acc.count * n);
if (!stride || stride === n * Comp.BYTES_PER_ELEMENT) {
out.set(new Comp(bin, base, acc.count * n));
} else {
for (let i = 0; i < acc.count; i++) {
const src = new Comp(bin, base + i*stride, n);
out.set(src, i*n);
}
}
return { data: out, acc };
}
function parseGLB(buf) {
const dv = new DataView(buf);
if (dv.getUint32(0, true) !== 0x46546C67) throw new Error('non è un file GLB');
let off = 12, json = null, bin = null;
while (off < buf.byteLength) {
const len = dv.getUint32(off, true), type = dv.getUint32(off+4, true);
const chunk = buf.slice(off+8, off+8+len);
if (type === 0x4E4F534A) json = JSON.parse(new TextDecoder().decode(chunk));
else if (type === 0x004E4942) bin = chunk;
off += 8 + len + (len % 4 ? 4 - len % 4 : 0);
}
if (!json || !bin) throw new Error('GLB incompleto');
// world transform per node (baked into the vertices below)
const worlds = {};
const walk = (ni, parent) => {
const node = json.nodes[ni];
const local = node.matrix ? new Float32Array(node.matrix)
: trsToMat(node.translation, node.rotation, node.scale);
const world = parent ? m4mul(parent, local) : local;
worlds[ni] = world;
(node.children || []).forEach(c => walk(c, world));
};
const scene = json.scenes[json.scene || 0];
scene.nodes.forEach(n => walk(n, null));
const prims = [];
let min = [1e9,1e9,1e9], max = [-1e9,-1e9,-1e9];
Object.keys(worlds).forEach(niKey => {
const ni = parseInt(niKey, 10);
const node = json.nodes[ni];
if (node.mesh == null) return;
const W = worlds[ni];
json.meshes[node.mesh].primitives.forEach(p => {
if ((p.mode || 4) !== 4 || p.attributes.POSITION == null) return;
const pos = readAccessor(json, bin, p.attributes.POSITION).data;
const nrm = p.attributes.NORMAL != null
? readAccessor(json, bin, p.attributes.NORMAL).data
: new Float32Array(pos.length); // flat fallback (lit by fresnel only)
const uv = p.attributes.TEXCOORD_0 != null
? readAccessor(json, bin, p.attributes.TEXCOORD_0).data
: new Float32Array(pos.length / 3 * 2);
// bake the node world transform (positions + normals)
for (let i = 0; i < pos.length; i += 3) {
const x = pos[i], y = pos[i+1], z = pos[i+2];
pos[i] = W[0]*x + W[4]*y + W[8]*z + W[12];
pos[i+1] = W[1]*x + W[5]*y + W[9]*z + W[13];
pos[i+2] = W[2]*x + W[6]*y + W[10]*z + W[14];
const nx = nrm[i], ny = nrm[i+1], nz = nrm[i+2];
nrm[i] = W[0]*nx + W[4]*ny + W[8]*nz;
nrm[i+1] = W[1]*nx + W[5]*ny + W[9]*nz;
nrm[i+2] = W[2]*nx + W[6]*ny + W[10]*nz;
for (let k = 0; k < 3; k++) {
const v = pos[i+k];
if (v < min[k]) min[k] = v;
if (v > max[k]) max[k] = v;
}
}
let idxData = null, idxType = 0;
if (p.indices != null) {
const r = readAccessor(json, bin, p.indices);
idxData = r.data instanceof Uint32Array || r.data instanceof Uint16Array
? r.data : Uint16Array.from(r.data);
idxType = idxData instanceof Uint32Array ? 5125 : 5123;
}
// base colour: texture (PNG/JPEG bytes) or factor
let texBytes = null, baseColor = [0.75, 0.75, 0.8];
const mat = p.material != null ? json.materials[p.material] : null;
const pbr = mat && mat.pbrMetallicRoughness || {};
if (pbr.baseColorFactor) baseColor = pbr.baseColorFactor.slice(0, 3);
if (pbr.baseColorTexture && json.textures && json.images) {
const tex = json.textures[pbr.baseColorTexture.index];
const img = json.images[tex.source];
if (img && img.bufferView != null) {
const bv = json.bufferViews[img.bufferView];
texBytes = { bytes: new Uint8Array(bin, bv.byteOffset || 0, bv.byteLength),
mime: img.mimeType || 'image/png' };
}
}
prims.push({ pos, nrm, uv, idxData, idxType, baseColor, texBytes });
});
});
if (!prims.length) throw new Error('nessuna mesh triangolare nel GLB');
return { prims, min, max };
}
// Generated fallback: a torus knot, so the family shows something before any
// GLB is loaded.
function torusKnot() {
const P = 2, Q = 3, SEG = 220, TUBE = 26, R2 = 0.34;
const pos = [], nrm = [], uv = [], idx = [];
const C = (t) => {
const r = 1 + 0.45*Math.cos(Q*t);
return [r*Math.cos(P*t), 0.45*Math.sin(Q*t), r*Math.sin(P*t)];
};
for (let i = 0; i <= SEG; i++) {
const t = i/SEG*Math.PI*2;
const c = C(t), c2 = C(t+0.01);
let tx = c2[0]-c[0], ty = c2[1]-c[1], tz = c2[2]-c[2];
const tl = Math.hypot(tx,ty,tz); tx/=tl; ty/=tl; tz/=tl;
let bx = tz, bz = -tx, bl = Math.hypot(bx,bz) || 1; bx/=bl; bz/=bl;
const nx0 = ty*bz, ny0 = tz*bx - tx*bz, nz0 = -ty*bx;
for (let j = 0; j <= TUBE; j++) {
const a = j/TUBE*Math.PI*2, ca = Math.cos(a), sa = Math.sin(a);
const nx = ca*bx + sa*nx0, ny = sa*ny0, nz = ca*bz + sa*nz0;
pos.push(c[0]+R2*nx, c[1]+R2*ny, c[2]+R2*nz);
nrm.push(nx, ny, nz);
uv.push(i/SEG*8, j/TUBE);
if (i < SEG && j < TUBE) {
const a0 = i*(TUBE+1)+j;
idx.push(a0, a0+TUBE+1, a0+1, a0+1, a0+TUBE+1, a0+TUBE+2);
}
}
}
return { prims: [{ pos: new Float32Array(pos), nrm: new Float32Array(nrm),
uv: new Float32Array(uv), idxData: new Uint32Array(idx), idxType: 5125,
baseColor: [0.72, 0.74, 0.85], texBytes: null }],
min: [-1.45,-0.8,-1.45], max: [1.45,0.8,1.45] };
}
// ------------------------------------------------------------ renderer
class ModelSim {
constructor(gl) {
this.gl = gl;
const compile = (type, src) => {
const sh = gl.createShader(type);
gl.shaderSource(sh, src); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
throw new Error('Model3D shader: ' + gl.getShaderInfoLog(sh));
return sh;
};
const prog = (v, f) => {
const p = gl.createProgram();
gl.attachShader(p, compile(gl.VERTEX_SHADER, v));
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, f));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
throw new Error('Model3D link: ' + gl.getProgramInfoLog(p));
return p;
};
this.progMesh = prog(MESH_VERT, MESH_FRAG);
this.progBg = prog(BG_VERT, BG_FRAG);
const U = (p, n) => gl.getUniformLocation(p, n);
this.um = { uProj: U(this.progMesh,'uProj'), uView: U(this.progMesh,'uView'),
uModel: U(this.progMesh,'uModel'), uPulse: U(this.progMesh,'uPulse'),
uTex: U(this.progMesh,'uTex'), uHasTex: U(this.progMesh,'uHasTex'),
uBase: U(this.progMesh,'uBase'), uColA: U(this.progMesh,'uColA'),
uColB: U(this.progMesh,'uColB'), uCam: U(this.progMesh,'uCam'),
uBeat: U(this.progMesh,'uBeat'), uLevel: U(this.progMesh,'uLevel'),
uTreble: U(this.progMesh,'uTreble') };
this.ub = { uColA: U(this.progBg,'uColA'), uColB: U(this.progBg,'uColB'),
uRes: U(this.progBg,'uRes'), uT: U(this.progBg,'uT'),
uBass: U(this.progBg,'uBass'), uBeat: U(this.progBg,'uBeat') };
this.aMesh = { pos: gl.getAttribLocation(this.progMesh,'aPos'),
nrm: gl.getAttribLocation(this.progMesh,'aNorm'),
uv: gl.getAttribLocation(this.progMesh,'aUV') };
this.aBg = gl.getAttribLocation(this.progBg, 'aPos');
this.quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
this.meshes = null;
this.loadError = '';
this._upload(torusKnot());
}
_freeMeshes() {
const gl = this.gl;
(this.meshes || []).forEach(m => {
gl.deleteBuffer(m.vboP); gl.deleteBuffer(m.vboN); gl.deleteBuffer(m.vboU);
if (m.ibo) gl.deleteBuffer(m.ibo);
if (m.tex) gl.deleteTexture(m.tex);
});
this.meshes = null;
}
_upload(model) {
const gl = this.gl;
this._freeMeshes();
const c = [(model.min[0]+model.max[0])/2, (model.min[1]+model.max[1])/2, (model.min[2]+model.max[2])/2];
this.center = c;
this.radius = Math.max(0.001, Math.hypot(model.max[0]-c[0], model.max[1]-c[1], model.max[2]-c[2]));
this.meshes = model.prims.map(p => {
const mk = (data) => {
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
return b;
};
const m = { vboP: mk(p.pos), vboN: mk(p.nrm), vboU: mk(p.uv),
count: p.idxData ? p.idxData.length : p.pos.length/3,
idxType: p.idxType, ibo: null, tex: null, baseColor: p.baseColor };
if (p.idxData) {
m.ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, p.idxData, gl.STATIC_DRAW);
}
if (p.texBytes) {
// decode the embedded PNG/JPEG asynchronously, then upload
m.tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, m.tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
new Uint8Array([180, 180, 200, 255]));
createImageBitmap(new Blob([p.texBytes.bytes], { type: p.texBytes.mime }))
.then(bmp => {
gl.bindTexture(gl.TEXTURE_2D, m.tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bmp);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
})
.catch(() => { /* keep the placeholder pixel */ });
}
return m;
});
}
// Load a GLB from an ArrayBuffer (called via the control panel).
setModel(buf) {
try {
this._upload(parseGLB(buf));
this.loadError = '';
return true;
} catch (e) {
this.loadError = e && e.message ? e.message : String(e);
if (!this.meshes) this._upload(torusKnot());
return false;
}
}
render(timeSec, audio, e, canvas) {
const gl = this.gl;
const speed = Math.min(2.5, e.speed || 1);
const mix = e.audioMix !== undefined ? e.audioMix : 1;
const bass = (audio.bass || 0)*mix, beat = (audio.beat || 0)*mix;
const ca = e.colorA || [0.05, 0, 0.2], cb = e.colorB || [0.2, 1, 1];
gl.viewport(0, 0, canvas.width, canvas.height);
gl.disable(gl.BLEND);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// backdrop
gl.useProgram(this.progBg);
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
gl.enableVertexAttribArray(this.aBg);
gl.vertexAttribPointer(this.aBg, 2, gl.FLOAT, false, 0, 0);
gl.uniform3fv(this.ub.uColA, ca);
gl.uniform3fv(this.ub.uColB, cb);
gl.uniform2f(this.ub.uRes, canvas.width, canvas.height);
gl.uniform1f(this.ub.uT, timeSec);
gl.uniform1f(this.ub.uBass, bass);
gl.uniform1f(this.ub.uBeat, beat);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// model
const asp = canvas.width/Math.max(1, canvas.height);
const dist = this.radius*2.6;
const yaw = timeSec*0.45*speed;
const eye = [Math.sin(timeSec*0.13)*this.radius*0.35,
this.radius*(0.25 + 0.15*Math.sin(timeSec*0.09)), dist];
const proj = m4persp(0.72, asp, dist*0.05, dist*4.0);
const view = m4lookAt(eye, [0, 0, 0]);
const scale = 1 + 0.05*bass + 0.07*beat;
const model = m4mul(m4mul(m4rotY(yaw), m4scaleT(scale, [0,0,0])),
m4scaleT(1, [-this.center[0], -this.center[1], -this.center[2]]));
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.useProgram(this.progMesh);
gl.uniformMatrix4fv(this.um.uProj, false, proj);
gl.uniformMatrix4fv(this.um.uView, false, view);
gl.uniformMatrix4fv(this.um.uModel, false, model);
gl.uniform1f(this.um.uPulse, this.radius*0.01*bass);
gl.uniform3fv(this.um.uColA, ca);
gl.uniform3fv(this.um.uColB, cb);
gl.uniform3fv(this.um.uCam, eye);
gl.uniform1f(this.um.uBeat, beat);
gl.uniform1f(this.um.uLevel, (audio.level || 0)*mix);
gl.uniform1f(this.um.uTreble, (audio.treble || 0)*mix);
for (const m of this.meshes) {
const bind = (buf, loc, n) => {
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, n, gl.FLOAT, false, 0, 0);
};
bind(m.vboP, this.aMesh.pos, 3);
bind(m.vboN, this.aMesh.nrm, 3);
bind(m.vboU, this.aMesh.uv, 2);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, m.tex || null);
gl.uniform1i(this.um.uTex, 0);
gl.uniform1i(this.um.uHasTex, m.tex ? 1 : 0);
gl.uniform3fv(this.um.uBase, m.baseColor);
if (m.ibo) {
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.ibo);
gl.drawElements(gl.TRIANGLES, m.count, m.idxType === 5125 ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT, 0);
} else {
gl.drawArrays(gl.TRIANGLES, 0, m.count);
}
}
gl.disable(gl.DEPTH_TEST);
gl.disableVertexAttribArray(this.aMesh.nrm);
gl.disableVertexAttribArray(this.aMesh.uv);
}
}
window.ModelSim = ModelSim;
})();
+1
View File
@@ -47,6 +47,7 @@
<script src="effects.js"></script> <script src="effects.js"></script>
<script src="fluid.js"></script> <script src="fluid.js"></script>
<script src="interactive.js"></script> <script src="interactive.js"></script>
<script src="model3d.js"></script>
<script src="visualizer.js"></script> <script src="visualizer.js"></script>
<script src="audio.js"></script> <script src="audio.js"></script>
<script src="output.js"></script> <script src="output.js"></script>
+11
View File
@@ -320,6 +320,17 @@ djv.onControl(async (m) => {
djv.report({ type: 'autoVj', on: autoVj }); djv.report({ type: 'autoVj', on: autoVj });
break; break;
case 'svg': loadCustomTexture(m.dataUrl); break; case 'svg': loadCustomTexture(m.dataUrl); break;
case 'glb':
try {
const bytes = await djv.readFile(m.path);
if (!bytes) throw new Error('file non leggibile');
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const ok = viz.setModelData(buf);
if (ok === false) throw new Error((viz.model3d && viz.model3d.loadError) || 'GLB non valido');
djv.report({ type: 'glbLoaded', name: m.path.split('/').pop() });
hideHint();
} catch (e) { djv.report({ type: 'error', message: 'GLB: ' + e.message }); }
break;
case 'ledArea': { case 'ledArea': {
// Confine the whole show to a W×H rectangle at X,Y (LED pixel mapping). // Confine the whole show to a W×H rectangle at X,Y (LED pixel mapping).
const st = $('#stage').style; const st = $('#stage').style;
+14
View File
@@ -86,6 +86,14 @@ class Visualizer {
this.effect = Object.assign({}, this.effect, effect); this.effect = Object.assign({}, this.effect, effect);
} }
// Load a GLB model for the 'Modello 3D' family. Parsed lazily on the next
// render so the engine is only created when the family is active.
setModelData(buf) {
if (this.model3d) return this.model3d.setModel(buf);
this._pendingGlb = buf;
return true;
}
// Upload a custom source (HTMLImageElement / HTMLCanvasElement) for the // Upload a custom source (HTMLImageElement / HTMLCanvasElement) for the
// SVG/Image effect family. Flipped on the Y axis to match screen space. // SVG/Image effect family. Flipped on the Y axis to match screen space.
setTexture(source) { setTexture(source) {
@@ -103,6 +111,12 @@ class Visualizer {
const gl = this.gl, u = this.u, e = this.effect; const gl = this.gl, u = this.u, e = this.effect;
// Release the camera as soon as a non-interactive preset takes over. // Release the camera as soon as a non-interactive preset takes over.
if (this.inter && !e.isInteractive) this.inter.suspend(); if (this.inter && !e.isInteractive) this.inter.suspend();
if (e.isModel3d && window.ModelSim) {
if (!this.model3d) this.model3d = new window.ModelSim(gl);
if (this._pendingGlb) { this.model3d.setModel(this._pendingGlb); this._pendingGlb = null; }
this.model3d.render(timeSec, audio, e, this.canvas);
return;
}
if (e.isInteractive && window.InteractiveSim) { if (e.isInteractive && window.InteractiveSim) {
if (!this.inter) this.inter = new window.InteractiveSim(gl); if (!this.inter) this.inter = new window.InteractiveSim(gl);
this.inter.customSource = this.customSource || null; this.inter.customSource = this.customSource || null;