Me llamo Jano. Nací en Ushuaia en el 1993. Vivo en City Bell. Actualmente soy artista visual y docente, actualmente en la cátedra de Teoría de La Práctica Artística en UNLP. FA. Investigo sobre la performatividad de las imágenes, con enfoque en la performatividad de las artes visuales. Modelo en arcilla y en dispositivos virtuales.
VIDEO
body {
margin: 0;
overflow: hidden;
background: #050505;
}
#contenedor {
width: 100%;
height: 650px;
}
{
“imports”: {
“three”: “https://cdn.jsdelivr.net/npm/
[email protected] /build/three.module.js”,
“three/addons/”: “https://cdn.jsdelivr.net/npm/
[email protected] /examples/jsm/”
}
}
import * as THREE from “three”;
import {
OrbitControls
} from “three/addons/controls/OrbitControls.js”;
const contenedor =
document.getElementById(“contenedor”);
const escena = new THREE.Scene();
escena.background =
new THREE.Color(0x050505);
// ==================================================
// CÁMARA Y RENDERIZADOR
// ==================================================
const camara =
new THREE.PerspectiveCamera(
50,
1,
0.1,
100
);
camara.position.set(0, 3, 8);
const renderizador =
new THREE.WebGLRenderer({
antialias: true
});
renderizador.setPixelRatio(
Math.min(window.devicePixelRatio, 2)
);
renderizador.outputColorSpace =
THREE.SRGBColorSpace;
renderizador.toneMapping =
THREE.ACESFilmicToneMapping;
renderizador.toneMappingExposure = 1.1;
contenedor.appendChild(
renderizador.domElement
);
const controles =
new OrbitControls(
camara,
renderizador.domElement
);
controles.enableDamping = true;
controles.dampingFactor = 0.05;
controles.minDistance = 4;
controles.maxDistance = 12;
// ==================================================
// LUCES
// ==================================================
const luzPrincipal =
new THREE.DirectionalLight(
0xffffff,
4
);
luzPrincipal.position.set(4, 6, 5);
escena.add(luzPrincipal);
const luzRelleno =
new THREE.DirectionalLight(
0xffffff,
1.5
);
luzRelleno.position.set(-4, 2, -3);
escena.add(luzRelleno);
// ==================================================
// PARÁMETROS
// ==================================================
const radioVertical = 1.5;
const radioEcuadorInicial = 1.5;
const radioEcuadorFinal = 3.2;
let radioEcuadorActual =
radioEcuadorInicial;
let progresoFormaActual = 0;
const centroEsfera =
new THREE.Vector3(
0,
0.75 + radioVertical,
0
);
function aleatorio(
minimo,
maximo
) {
return (
minimo +
Math.random() *
(maximo – minimo)
);
}
function limitar(
valor,
minimo,
maximo
) {
return Math.max(
minimo,
Math.min(maximo, valor)
);
}
// ==================================================
// COLISIONES DEL ENTORNO
// ==================================================
/*
La esfera central puede atravesar los prismas.
Las colisiones sólo se evitan entre prismas y
esferas de colores.
*/
const objetosColision = [];
const margenColision = 0.12;
function registrarColision(
objeto,
radio
) {
objetosColision.push({
objeto: objeto,
radio: radio
});
}
function obtenerPosicionLibre(
radioNuevo,
objetoIgnorado = null,
// Distancia mínima al centro de la esfera.
// 0 = sin exclusión (así se colocan los
// prismas al crearse, para que la esfera
// los pueda “absorber” desde el principio).
// Al liberar un prisma sí conviene excluir
// esta zona, para que la expulsión realmente
// lo aleje del vidrio en vez de arriesgarse
// a devolverlo cerca del centro.
distanciaMinimaCentro = 0
) {
for (
let intento = 0;
intento 0 &&
posicion.distanceTo(
centroEsfera
) <
distanciaMinimaCentro
) {
continue;
}
let hayColision = false;
for (
const registro of objetosColision
) {
if (
registro.objeto ===
objetoIgnorado
) {
continue;
}
if (
posicion.distanceTo(
registro.objeto.position
) <
radioNuevo +
registro.radio +
margenColision
) {
hayColision = true;
break;
}
}
if (!hayColision) {
return posicion;
}
}
return null;
}
// ==================================================
// ESFERAS DE COLOR
// ==================================================
const objetosEntorno = [];
const lucesEntorno = [];
const coloresEsferas = [
0xff3030,
0x3080ff,
0xffcc30,
0x40ff80,
0xff50d0,
0x50ffff
];
const amplitudEsferas = 0.06;
function crearEsferaEntorno(
x,
y,
z,
radio,
color
) {
const esferaEntorno =
new THREE.Mesh(
new THREE.SphereGeometry(
radio,
32,
32
),
new THREE.MeshPhysicalMaterial({
color: color,
roughness: 0.18,
metalness: 0.05,
clearcoat: 0.7,
clearcoatRoughness: 0.08
})
);
esferaEntorno.position.set(x, y, z);
esferaEntorno.userData.base =
esferaEntorno.position.clone();
escena.add(esferaEntorno);
objetosEntorno.push(esferaEntorno);
const luz =
new THREE.PointLight(
color,
2,
6
);
luz.position.copy(
esferaEntorno.position
);
escena.add(luz);
lucesEntorno.push(luz);
registrarColision(
esferaEntorno,
radio + amplitudEsferas
);
}
crearEsferaEntorno(
-4.0, 3.5, -2.5, 0.75,
coloresEsferas[0]
);
crearEsferaEntorno(
4.2, 3.0, -1.5, 0.65,
coloresEsferas[1]
);
crearEsferaEntorno(
-3.5, 0.0, -3.0, 0.55,
coloresEsferas[2]
);
crearEsferaEntorno(
3.5, -0.5, -2.5, 0.85,
coloresEsferas[3]
);
crearEsferaEntorno(
-2.5, 5.0, 1.0, 0.45,
coloresEsferas[4]
);
crearEsferaEntorno(
2.5, 4.5, 1.5, 0.50,
coloresEsferas[5]
);
// ==================================================
// PRISMAS
// ==================================================
const prismas = [];
const coloresPrismas = [
0xff1744,
0xff6d00,
0xffd600,
0x00e676,
0x00b0ff,
0x651fff,
0xd500f9,
0xffffff
];
function crearPrisma() {
const radio =
aleatorio(0.12, 0.38);
const altura =
aleatorio(0.5, 1.7);
const amplitud =
aleatorio(0.08, 0.16);
const radioGeometrico =
Math.sqrt(
radio * radio +
(altura / 2) *
(altura / 2)
);
const radioColision =
radioGeometrico +
amplitud * 1.55;
const posicion =
obtenerPosicionLibre(
radioColision
);
if (!posicion) {
return;
}
const color =
coloresPrismas[
Math.floor(
Math.random() *
coloresPrismas.length
)
];
const prisma =
new THREE.Mesh(
new THREE.CylinderGeometry(
radio,
radio,
altura,
6
),
new THREE.MeshPhysicalMaterial({
color: color,
roughness: 0.22,
metalness: 0.05,
clearcoat: 0.8,
clearcoatRoughness: 0.08,
emissive: new THREE.Color(color),
emissiveIntensity: 0.02
})
);
prisma.position.copy(posicion);
prisma.rotation.set(
aleatorio(0, Math.PI),
aleatorio(0, Math.PI),
aleatorio(0, Math.PI)
);
prisma.userData.base =
posicion.clone();
prisma.userData.fase =
aleatorio(0, Math.PI * 2);
prisma.userData.amplitud =
amplitud;
prisma.userData.radioGeometrico =
radioGeometrico;
prisma.userData.radioColision =
radioColision;
prisma.userData.estado =
"flotando";
prisma.userData.capturadoEn =
null;
prisma.userData.posicionAbsorbida =
null;
prisma.userData.destinoLiberacion =
null;
prisma.userData.protegidoHasta =
0;
escena.add(prisma);
prismas.push(prisma);
registrarColision(
prisma,
radioColision
);
}
const cantidadPrismas =
Math.floor(
aleatorio(10, 18)
);
for (
let i = 0;
i < cantidadPrismas;
i++
) {
crearPrisma();
}
// ==================================================
// ESFERA CENTRAL
// ==================================================
const segmentos = 128;
const anillos = 64;
const verticesBase = [];
const vertices = [];
const indices = [];
for (
let i = 0;
i <= anillos;
i++
) {
const phi =
Math.PI * i / anillos;
for (
let j = 0;
j <= segmentos;
j++
) {
const theta =
2 * Math.PI * j / segmentos;
const x =
Math.sin(phi) *
Math.cos(theta);
const y =
Math.cos(phi);
const z =
Math.sin(phi) *
Math.sin(theta);
verticesBase.push(x, y, z);
vertices.push(
x * radioEcuadorInicial,
y * radioVertical,
z * radioEcuadorInicial
);
}
}
for (
let i = 0;
i < anillos;
i++
) {
for (
let j = 0;
j < segmentos;
j++
) {
const a =
i *
(segmentos + 1) +
j;
const b = a + 1;
const c =
(i + 1) *
(segmentos + 1) +
j;
const d = c + 1;
indices.push(a, c, b);
indices.push(b, c, d);
}
}
const geometria =
new THREE.BufferGeometry();
geometria.setAttribute(
"position",
new THREE.Float32BufferAttribute(
vertices,
3
)
);
geometria.setIndex(indices);
geometria.computeVertexNormals();
// ==================================================
// CUBEMAP Y VIDRIO
// ==================================================
const renderTargetCubo =
new THREE.WebGLCubeRenderTarget(
128,
{
generateMipmaps: true,
minFilter:
THREE.LinearMipmapLinearFilter
}
);
const cubeCamara =
new THREE.CubeCamera(
0.1,
100,
renderTargetCubo
);
cubeCamara.position.copy(
centroEsfera
);
escena.add(cubeCamara);
const materialVidrio =
new THREE.MeshPhysicalMaterial({
color: 0xffffff,
roughness: 0.015,
transmission: 1,
thickness: 0.65,
ior: 1.50,
attenuationColor:
new THREE.Color(0xf4f7ff),
attenuationDistance: 7,
clearcoat: 1,
clearcoatRoughness: 0.01,
envMap: renderTargetCubo.texture,
envMapIntensity: 1.2
});
const esfera =
new THREE.Mesh(
geometria,
materialVidrio
);
esfera.position.copy(centroEsfera);
escena.add(esfera);
// ==================================================
// LÁPIZ
// ==================================================
const grupoLapiz =
new THREE.Group();
const radioLapiz = 0.16;
const cuerpoLapiz =
new THREE.Mesh(
new THREE.CylinderGeometry(
radioLapiz,
radioLapiz,
3.4,
32
),
new THREE.MeshPhysicalMaterial({
color: 0xd8b25c,
roughness: 0.38,
metalness: 0.05
})
);
cuerpoLapiz.position.y = -1.5;
const punta =
new THREE.Mesh(
new THREE.ConeGeometry(
radioLapiz,
0.55,
32
),
new THREE.MeshPhysicalMaterial({
color: 0x333333,
roughness: 0.65
})
);
punta.position.y = 0.475;
grupoLapiz.add(cuerpoLapiz);
grupoLapiz.add(punta);
escena.add(grupoLapiz);
// ==================================================
// RELOJ INTERNO
// ==================================================
const canvasReloj =
document.createElement("canvas");
canvasReloj.width = 2048;
canvasReloj.height = 512;
const ctx =
canvasReloj.getContext("2d");
const texturaReloj =
new THREE.CanvasTexture(
canvasReloj
);
texturaReloj.colorSpace =
THREE.SRGBColorSpace;
const radioInterno = 0.72;
const anchoCurvatura = Math.PI * 0.72;
const altoReloj = 0.36;
const segmentosReloj = 96;
const posicionesReloj = [];
const uvsReloj = [];
const indicesReloj = [];
for (
let fila = 0;
fila <= 1;
fila++
) {
const y =
(fila – 0.5) *
altoReloj;
for (
let i = 0;
i <= segmentosReloj;
i++
) {
const t =
i / segmentosReloj;
const angulo =
(t – 0.5) *
anchoCurvatura;
posicionesReloj.push(
radioInterno *
Math.sin(angulo),
y,
radioInterno *
Math.cos(angulo)
);
uvsReloj.push(t, fila);
}
}
for (
let i = 0;
i =
1000
) {
const partes =
formateadorHoraAR.formatToParts(
new Date()
);
const hora = {
horas: 0,
minutos: 0,
segundos: 0
};
for (
const parte of partes
) {
if (parte.type === “hour”) {
hora.horas =
Number(parte.value);
}
if (parte.type === “minute”) {
hora.minutos =
Number(parte.value);
}
if (parte.type === “second”) {
hora.segundos =
Number(parte.value);
}
}
horaCacheada = hora;
ultimaConsultaHora = ahora;
}
return horaCacheada;
}
const parametrosURL =
new URLSearchParams(
window.location.search
);
let modoPrueba =
parametrosURL.get(“debug”) === “1”;
let tiempoPrueba =
limitar(
Number(
parametrosURL.get(“hora”)
) || 14,
14,
16
);
let reproduciendoPrueba = false;
let ultimoTiempoPrueba =
performance.now();
const velocidadPrueba =
2 / 30;
function horaDesdeDecimal(
tiempoDecimal
) {
const segundosTotales =
Math.round(
tiempoDecimal * 3600
);
return {
horas:
Math.floor(
segundosTotales / 3600
),
minutos:
Math.floor(
segundosTotales % 3600 / 60
),
segundos:
segundosTotales % 60
};
}
function obtenerHoraEscena() {
if (!modoPrueba) {
return obtenerHoraArgentina();
}
const ahora =
performance.now();
const delta =
(ahora – ultimoTiempoPrueba) /
1000;
ultimoTiempoPrueba = ahora;
if (reproduciendoPrueba) {
tiempoPrueba =
Math.min(
16,
tiempoPrueba +
delta *
velocidadPrueba
);
if (tiempoPrueba >= 16) {
reproduciendoPrueba = false;
}
}
return horaDesdeDecimal(
tiempoPrueba
);
}
// ==================================================
// CICLO DEL ORGANISMO
// ==================================================
/*
reloj: responde a la hora argentina.
absorbiendo: ciclo manual de 30 segundos.
absorbido: esfera expandida.
expulsando: al hacer clic sobre el lápiz.
reconectando: transición corta que retoma
el tamaño que le corresponde según la hora
real, sin saltos.
reposo: esfera contraída y prismas libres
(solo se usa como paso intermedio antes de
reconectar).
*/
let faseOrganismo = “reloj”;
let inicioFase =
performance.now();
let progresoManual = 0;
let progresoInicioExpulsion = 0;
let colaLiberacion = [];
let indiceLiberacion = 0;
const duracionAbsorcion = 30000;
const duracionExpulsion = 6500;
// Duración de la transición de vuelta al
// reloj real, una vez que termina la
// expulsión — evita el salto brusco.
const duracionReconexion = 2500;
let progresoObjetivoReconexion = 0;
function smoothstep(
inicio,
fin,
valor
) {
const t =
limitar(
(valor – inicio) /
(fin – inicio),
0,
1
);
return t * t * (3 – 2 * t);
}
function progresoDesdeHora(hora) {
const tiempo =
hora.horas +
hora.minutos / 60 +
hora.segundos / 3600;
return limitar(
(tiempo – 14) / 2,
0,
1
);
}
function obtenerProgresoForma(hora) {
if (faseOrganismo === “reloj”) {
return progresoDesdeHora(hora);
}
if (faseOrganismo === “reposo”) {
return 0;
}
if (faseOrganismo === “absorbido”) {
return 1;
}
if (faseOrganismo === “absorbiendo”) {
return progresoManual;
}
if (faseOrganismo === “expulsando”) {
return (
progresoInicioExpulsion *
(1 – progresoManual)
);
}
if (faseOrganismo === “reconectando”) {
return progresoManual;
}
return 0;
}
function actualizarOrganismo(hora) {
const ahora =
performance.now();
if (
faseOrganismo ===
“absorbiendo”
) {
progresoManual =
limitar(
(ahora – inicioFase) /
duracionAbsorcion,
0,
1
);
if (progresoManual >= 1) {
faseOrganismo =
“absorbido”;
}
}
if (
faseOrganismo ===
“expulsando”
) {
progresoManual =
limitar(
(ahora – inicioFase) /
duracionExpulsion,
0,
1
);
const intervalo =
duracionExpulsion /
Math.max(
colaLiberacion.length,
1
);
while (
indiceLiberacion =
indiceLiberacion * intervalo
) {
liberarPrisma(
colaLiberacion[
indiceLiberacion
]
);
indiceLiberacion++;
}
if (progresoManual >= 1) {
// En vez de quedar en “reposo” para
// siempre, se arranca una transición
// corta de vuelta al tamaño que le
// corresponde según la hora real, para
// no perder la sincronía con el reloj.
faseOrganismo =
“reconectando”;
inicioFase =
performance.now();
progresoObjetivoReconexion =
progresoDesdeHora(hora);
}
}
if (
faseOrganismo ===
“reconectando”
) {
const t =
limitar(
(ahora – inicioFase) /
duracionReconexion,
0,
1
);
progresoManual =
progresoObjetivoReconexion *
smoothstep(
0,
1,
t
);
if (t >= 1) {
faseOrganismo = “reloj”;
}
}
}
function iniciarAbsorcionManual() {
faseOrganismo =
“absorbiendo”;
inicioFase =
performance.now();
progresoManual = 0;
}
function iniciarExpulsion() {
progresoInicioExpulsion =
progresoFormaActual;
colaLiberacion =
prismas.filter(
(prisma) =>
prisma.userData.estado ===
“absorbido”
);
indiceLiberacion = 0;
faseOrganismo =
“expulsando”;
inicioFase =
performance.now();
progresoManual = 0;
}
// ==================================================
// FORMA Y RELOJ
// ==================================================
let progresoAnterior = -1;
function actualizarForma(
progreso
) {
if (
progreso ===
progresoAnterior
) {
return;
}
progresoAnterior = progreso;
progresoFormaActual = progreso;
const radioEcuador =
radioEcuadorInicial +
(
radioEcuadorFinal –
radioEcuadorInicial
) *
smoothstep(
0,
1,
progreso
);
radioEcuadorActual =
radioEcuador;
const posiciones =
geometria.attributes.position;
for (
let i = 0;
i < posiciones.count;
i++
) {
posiciones.setXYZ(
i,
verticesBase[i * 3] *
radioEcuador,
verticesBase[i * 3 + 1] *
radioVertical,
verticesBase[i * 3 + 2] *
radioEcuador
);
}
posiciones.needsUpdate = true;
geometria.computeVertexNormals();
}
let textoAnterior = "";
function actualizarReloj(hora) {
const texto =
String(hora.horas)
.padStart(2, "0")
+ ":" +
String(hora.minutos)
.padStart(2, "0")
+ ":" +
String(hora.segundos)
.padStart(2, "0");
if (texto === textoAnterior) {
return;
}
textoAnterior = texto;
ctx.clearRect(
0,
0,
canvasReloj.width,
canvasReloj.height
);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font =
"bold 361px monospace";
ctx.shadowColor =
"rgba(255,0,0,0.60)";
ctx.shadowBlur = 12;
ctx.fillStyle = "#ff1010";
ctx.fillText(
texto,
canvasReloj.width / 2,
canvasReloj.height / 2
);
ctx.shadowBlur = 2;
ctx.fillStyle = "#ff3030";
ctx.fillText(
texto,
canvasReloj.width / 2,
canvasReloj.height / 2
);
texturaReloj.needsUpdate = true;
}
// ==================================================
// ABSORCIÓN Y LIBERACIÓN
// ==================================================
function prismaTocadoPorVidrio(
prisma
) {
const datos =
prisma.userData;
const dx =
prisma.position.x /
radioEcuadorActual;
const dy =
(
prisma.position.y –
centroEsfera.y
) /
radioVertical;
const dz =
prisma.position.z /
radioEcuadorActual;
const distanciaNormalizada =
Math.sqrt(
dx * dx +
dy * dy +
dz * dz
);
// El centro del prisma está adentro del
// elipsoide: sin dudas está tocado.
if (distanciaNormalizada <= 1) {
return true;
}
if (distanciaNormalizada === 0) {
return true;
}
// Afuera: se proyecta el punto sobre la
// superficie del elipsoide, en la misma
// dirección respecto del centro, y se mide
// la distancia REAL (en unidades del mundo,
// no normalizadas) entre el prisma y esa
// superficie. Comparar esa distancia física
// contra radioGeometrico evita el error de
// escala que tenía el margen normalizado
// anterior (que se volvía desproporcionado
// en los ejes X/Z a medida que el vidrio
// crecía).
const factorSuperficie =
1 / distanciaNormalizada;
const superficieX =
prisma.position.x *
factorSuperficie;
const superficieY =
centroEsfera.y +
(
prisma.position.y –
centroEsfera.y
) *
factorSuperficie;
const superficieZ =
prisma.position.z *
factorSuperficie;
const distanciaRealALaSuperficie =
Math.sqrt(
(
prisma.position.x –
superficieX
) ** 2 +
(
prisma.position.y –
superficieY
) ** 2 +
(
prisma.position.z –
superficieZ
) ** 2
);
return (
distanciaRealALaSuperficie <=
datos.radioGeometrico
);
}
function absorberPrisma(
prisma
) {
const datos =
prisma.userData;
datos.estado = "absorbido";
datos.capturadoEn =
performance.now();
datos.posicionAbsorbida =
prisma.position.clone();
prisma.material.emissiveIntensity =
0.38;
}
function liberarPrisma(
prisma
) {
const datos =
prisma.userData;
const destino =
obtenerPosicionLibre(
datos.radioColision,
prisma,
// Que quede claramente afuera del
// volumen máximo que puede alcanzar
// el vidrio, no solo del actual.
radioEcuadorFinal + 0.3
);
if (!destino) {
return;
}
datos.estado = "liberando";
datos.inicioLiberacion =
performance.now();
datos.origenLiberacion =
prisma.position.clone();
datos.destinoLiberacion =
destino;
// Punto de control para una curva (Bézier
// cuadrática) en vez de una línea recta:
// el prisma se "empuja" primero hacia afuera
// desde el centro de la esfera, así no se ve
// cruzando el vidrio en línea recta hacia el
// punto nuevo.
const direccionSalida =
datos.origenLiberacion
.clone()
.sub(centroEsfera);
if (
direccionSalida.lengthSq() {
const datos =
prisma.userData;
if (
datos.estado ===
“absorbido”
) {
return;
}
if (
datos.estado ===
“liberando”
) {
const progreso =
limitar(
(
performance.now() –
datos.inicioLiberacion
) / 1100,
0,
1
);
const t =
smoothstep(
0,
1,
progreso
);
const unoMenosT =
1 – t;
// Bézier cuadrática: origen -> control
// (empuje hacia afuera) -> destino.
prisma.position.set(
unoMenosT * unoMenosT *
datos.origenLiberacion.x +
2 * unoMenosT * t *
datos.controlLiberacion.x +
t * t *
datos.destinoLiberacion.x,
unoMenosT * unoMenosT *
datos.origenLiberacion.y +
2 * unoMenosT * t *
datos.controlLiberacion.y +
t * t *
datos.destinoLiberacion.y,
unoMenosT * unoMenosT *
datos.origenLiberacion.z +
2 * unoMenosT * t *
datos.controlLiberacion.z +
t * t *
datos.destinoLiberacion.z
);
if (progreso >= 1) {
datos.base.copy(
datos.destinoLiberacion
);
datos.estado = “flotando”;
// Breve período de gracia: recién
// liberado, no puede ser vuelto a
// absorber de inmediato aunque caiga
// cerca de la esfera mientras esta
// vuelve a crecer.
datos.protegidoHasta =
performance.now() +
1500;
}
return;
}
prisma.rotation.y +=
0.0005 +
indice * 0.00001;
prisma.position.x =
datos.base.x +
Math.cos(
tiempo * 1.1 +
datos.fase
) *
datos.amplitud;
prisma.position.y =
datos.base.y +
Math.sin(
tiempo * 1.4 +
datos.fase
) *
datos.amplitud;
prisma.position.z =
datos.base.z +
Math.sin(
tiempo * 0.9 +
datos.fase * 1.7
) *
(
datos.amplitud * 0.6
);
/*
Durante reposo, los prismas son libres.
En cualquier fase de crecimiento, el
contacto los transforma en memoria —
salvo que esté en su período de gracia
recién liberado.
*/
const enGracia =
datos.protegidoHasta &&
performance.now() {
objeto.position.y =
objeto.userData.base.y +
Math.sin(
tiempo +
indice
) *
amplitudEsferas;
lucesEntorno[indice]
.position
.copy(objeto.position);
}
);
}
// ==================================================
// REFLEJOS DEL VIDRIO
// ==================================================
let contadorReflejos = 0;
function actualizarReflejos() {
contadorReflejos++;
if (
contadorReflejos % 8 !== 0
) {
return;
}
esfera.visible = false;
cubeCamara.update(
renderizador,
escena
);
esfera.visible = true;
}
// ==================================================
// INTERACCIÓN CON EL LÁPIZ
// ==================================================
const raycaster =
new THREE.Raycaster();
const puntero =
new THREE.Vector2();
renderizador.domElement.addEventListener(
“click”,
(evento) => {
const rect =
renderizador.domElement
.getBoundingClientRect();
puntero.x =
(
(evento.clientX – rect.left) /
rect.width
) *
2 – 1;
puntero.y =
-(
(evento.clientY – rect.top) /
rect.height
) *
2 + 1;
raycaster.setFromCamera(
puntero,
camara
);
const impacto =
raycaster.intersectObjects(
grupoLapiz.children,
false
);
if (!impacto.length) {
return;
}
/*
Mientras está en transición (expulsando
o reconectando con el reloj), se ignoran
los clics para no pisar la animación.
Si tiene piezas absorbidas, el lápiz las
expulsa. Si no tiene ninguna, dispara una
absorción manual (para poder ver el efecto
sin esperar a la franja horaria real).
*/
if (
faseOrganismo === “expulsando” ||
faseOrganismo === “reconectando”
) {
return;
}
const hayAbsorbidos =
prismas.some(
(prisma) =>
prisma.userData.estado ===
“absorbido”
);
if (hayAbsorbidos) {
iniciarExpulsion();
} else if (
faseOrganismo !== “absorbiendo”
) {
iniciarAbsorcionManual();
}
}
);
// ==================================================
// CONTROLES DEL MODO DEBUG
// ==================================================
window.addEventListener(
“keydown”,
(evento) => {
if (
evento.key.toLowerCase() ===
“d”
) {
modoPrueba = !modoPrueba;
reproduciendoPrueba = false;
tiempoPrueba = 14;
ultimoTiempoPrueba =
performance.now();
return;
}
if (!modoPrueba) {
return;
}
if (
evento.code === “Space”
) {
evento.preventDefault();
reproduciendoPrueba =
!reproduciendoPrueba;
}
if (
evento.key === “ArrowRight”
) {
evento.preventDefault();
tiempoPrueba =
limitar(
tiempoPrueba + 5 / 60,
14,
16
);
}
if (
evento.key === “ArrowLeft”
) {
evento.preventDefault();
tiempoPrueba =
limitar(
tiempoPrueba – 5 / 60,
14,
16
);
}
if (
evento.key.toLowerCase() ===
“r”
) {
tiempoPrueba = 14;
reproduciendoPrueba = false;
}
}
);
// ==================================================
// TAMAÑO RESPONSIVO
// ==================================================
function ajustarRenderizado() {
const ancho =
contenedor.clientWidth;
const alto =
contenedor.clientHeight;
if (
ancho === 0 ||
alto === 0
) {
return;
}
camara.aspect =
ancho / alto;
camara.updateProjectionMatrix();
renderizador.setSize(
ancho,
alto,
false
);
}
const observadorResize =
new ResizeObserver(
ajustarRenderizado
);
observadorResize.observe(
contenedor
);
ajustarRenderizado();
// ==================================================
// ANIMACIÓN
// ==================================================
function animar() {
requestAnimationFrame(animar);
const hora =
obtenerHoraEscena();
actualizarOrganismo(hora);
actualizarForma(
obtenerProgresoForma(hora)
);
actualizarReloj(hora);
const tiempo =
performance.now() * 0.0002;
actualizarEsferasEntorno(tiempo);
actualizarPrismas(tiempo);
actualizarReflejos();
controles.update();
renderizador.render(
escena,
camara
);
}
animar();