Skip to content

Renderers

Los renderers de Oroya Animate son traductores que convierten el scene graph agnóstico en salida visual. El core no conoce a los renderers — cada uno lee el scene graph y produce su propia representación.


Visión general

graph TD
    SG["Scene Graph\n(@joroya/core)"]
    R3["ThreeRenderer\n(@joroya/renderer-three)"]
    RS["renderToSVG\n(@joroya/renderer-svg)"]
    WEBGL["WebGL Canvas (3D)"]
    SVG["SVG String (2D)"]

    SG -->|"mount + render"| R3
    SG -->|"function call"| RS
    R3 --> WEBGL
    RS --> SVG
AspectoThreeRendererrenderToSVG / renderToSVGElement / SvgRenderer
ParadigmaInstancia con estado (class)String, instantánea DOM o clase persistente
OutputDibuja en un <canvas>SVG como string o elemento DOM
Requiere DOMSí (HTMLCanvasElement)String: no; instantánea/clase DOM: sí
3D✅ Perspectiva, luces, sombras❌ Solo 2D
Vectorial❌ Rasterizado✅ Infinitamente escalable

@joroya/renderer-three — Three.js (WebGL)

El renderer principal para visualización 3D interactiva.

Setup

import { ThreeRenderer } from '@joroya/renderer-three';

const renderer = new ThreeRenderer({
  canvas: document.getElementById('canvas') as HTMLCanvasElement,
  width: window.innerWidth,
  height: window.innerHeight,
  dpr: window.devicePixelRatio,  // opcional
});

Opciones del constructor

OpciónTipoDefaultDescripción
canvasHTMLCanvasElement(requerido)Elemento canvas destino
widthnumber(requerido)Ancho del viewport
heightnumber(requerido)Alto del viewport
dprnumberwindow.devicePixelRatioDevice pixel ratio (HiDPI)

Métodos

MétodoDescripción
mount(scene)Conecta una escena. Reconstruye la escena Three.js, detecta la cámara activa, agrega luces
render()Sincroniza transforms, propaga matrices y dibuja un frame
dispose()Libera recursos WebGL

Ciclo de vida

sequenceDiagram
    participant U as User Code
    participant TR as ThreeRenderer
    participant TS as THREE.Scene

    Note over U,TS: Montaje
    U->>TR: mount(scene)
    TR->>TS: clear + add lights
    TR->>TR: traverse → create Mesh/Group/Camera per node
    TR->>TR: Set first Camera as activeCamera

    Note over U,TS: Render loop
    loop requestAnimationFrame
        U->>TR: render()
        TR->>TR: updateWorldMatrices()
        TR->>TS: sync worldMatrix → Three.js objects
        TR->>TR: webglRenderer.render()
    end

Traducción de componentes

Nodo OroyaObjeto Three.js
Node sin Geometry ni CameraTHREE.Group
Node + Geometry(Box)THREE.Mesh(BoxGeometry)
Node + Geometry(Sphere)THREE.Mesh(SphereGeometry)
Node + Geometry(Path2D)❌ Ignorado
Node + Camera(Perspective)THREE.PerspectiveCamera
Material con colorMeshStandardMaterial({ color })
Material con opacity < 1MeshStandardMaterial({ transparent: true })
Sin MaterialMeshStandardMaterial({ color: 0xcccccc })

Iluminación

Añade componentes Light explícitos. No se inyectan luces por defecto; los materiales MeshStandardMaterial requieren iluminación. Se admiten luces Ambient, Directional, Point y Spot.

Resolución de cámaras

flowchart TD
    START["mount(scene)"] --> TRAVERSE["Traverse scene graph"]
    TRAVERSE --> FOUND{"¿Encontró Camera?"}
    FOUND -->|"Sí"| USE["Usar primera como activa"]
    FOUND -->|"No"| FALLBACK["Fallback: PerspectiveCamera, FOV 75, z=5"]

Ejemplo completo

import {
  Camera, CameraType, Light, LightType, Material,
  Node, Scene, createBox, setFromAxisAngle,
} from '@joroya/core';
import { ThreeRenderer } from '@joroya/renderer-three';

const canvas = document.querySelector<HTMLCanvasElement>('#canvas');
if (!canvas) throw new Error('Canvas element not found');

const scene = new Scene();
const camera = new Node('camera');
camera.addComponent(new Camera({
  type: CameraType.Perspective,
  fov: 60,
  aspect: window.innerWidth / window.innerHeight,
  near: 0.1,
  far: 100,
}));
camera.transform.position.z = 5;
scene.add(camera);

const ambient = new Node('ambient');
ambient.addComponent(new Light({ type: LightType.Ambient, intensity: 0.6 }));
scene.add(ambient);

const sun = new Node('sun');
sun.addComponent(new Light({ type: LightType.Directional, intensity: 1.5 }));
sun.transform.position = { x: 3, y: 5, z: 4 };
scene.add(sun);

const cube = new Node('cube');
cube.addComponent(createBox());
cube.addComponent(new Material({ color: { r: 1, g: 0.2, b: 0.2 } }));
scene.add(cube);

const renderer = new ThreeRenderer({
  canvas,
  width: window.innerWidth,
  height: window.innerHeight,
});
renderer.mount(scene);

let previousTime: number | undefined;
let elapsed = 0;
let frameId = 0;
function frame(time: number) {
  const dt = previousTime === undefined ? 0 : (time - previousTime) / 1000;
  previousTime = time;
  elapsed += dt;
  cube.transform.rotation = setFromAxisAngle({ x: 0, y: 1, z: 0 }, elapsed * 0.5);
  cube.transform.updateLocalMatrix();
  renderer.render(dt);
  frameId = requestAnimationFrame(frame);
}
frameId = requestAnimationFrame(frame);

function stop() {
  cancelAnimationFrame(frameId);
  renderer.dispose();
}
window.addEventListener('pagehide', stop, { once: true });

@joroya/renderer-svg — SVG (2D)

Renderer ligero que genera markup SVG. Ideal para arte generativo, exportación vectorial y server-side rendering.

SvgRenderer — Escenas persistentes en navegador (desde 1.3.0)

Usa SvgRenderer para animaciones SVG continuas. Conserva el SVG y los grupos por nodo; actualiza transformaciones, texto y materiales, reconcilia cambios de estructura y delega interacciones. Requiere DOM; renderToSVG sigue siendo la API de exportación compatible con servidor.

import { Scene, Node, Material, createBox } from '@joroya/core';
import { SvgRenderer } from '@joroya/renderer-svg';

const scene = new Scene();
const card = new Node('card');
card.addComponent(createBox(80, 40, 0));
card.addComponent(new Material({ fill: { r: 0.95, g: 0.42, b: 0.23 } }));
scene.add(card);
const renderer = new SvgRenderer({
  width: 640, height: 320, container: document.getElementById('scene')!,
  title: 'Iteración de arrays', description: 'La tarjeta muestra el elemento activo.',
});
renderer.mount(scene); // Dibuja sin avanzar el reloj.
card.transform.setPosition({ x: 160, y: 120, z: 0 });
renderer.render(0, { advanceScene: false });
// Después de detener el RAF de tu aplicación:
renderer.dispose();

render(dt) avanza la escena una vez. Después de mixer.seek(time) o scene.update(dt), dibuja con render(0, { advanceScene: false }). Enlaza componentes Animator sin mixer y libera solamente sus propios enlaces; los mixers externos siguen bajo control de la aplicación. No inicia RAF. setSize, setViewBox, setAccessibleDescription y getNodeElement(node.id) permiten adaptación de tamaño y anotaciones accesibles que conservan su identidad. Para explicaciones didácticas, deriva texto y valores de snapshots deterministas: los eventos de clips no deben ser la única fuente del estado. Consulta la guía del paquete.

renderToSVG — String SVG (server-safe)

Retorna un string SVG y actualiza la lógica de la escena. Funciona en Node.js sin DOM.

import { renderToSVG } from '@joroya/renderer-svg';

const svg: string = renderToSVG(scene, { width: 400, height: 300 });

Opciones (SvgRenderOptions)

OpciónTipoDefaultDescripción
widthnumber(requerido)Ancho del SVG
heightnumber(requerido)Alto del SVG
viewBoxstring"0 0 {width} {height}"viewBox personalizado

renderToSVGElement — DOM interactivo

Crea un SVGSVGElement real con event delegation. Los nodos con componente Interactive reciben listeners de pointer/click/wheel.

import { renderToSVGElement } from '@joroya/renderer-svg';

const { svg, dispose } = renderToSVGElement(scene, {
  width: 800,
  height: 600,
  container: document.getElementById('app')!,
});

// Cuando ya no se necesite:
dispose(); // Limpia listeners y remueve el SVG del DOM

Opciones (SvgElementRenderOptions)

Extiende SvgRenderOptions con:

OpciónTipoDescripción
containerHTMLElement(opcional) Elemento padre donde se adjunta el SVG automáticamente

Retorno

CampoTipoDescripción
svgSVGSVGElementEl elemento SVG creado
dispose() => voidLimpia event listeners y remueve el SVG del DOM

Eventos interactivos soportados

Evento DOMInteractionEventType
clickClick
pointerdownPointerDown
pointerupPointerUp
pointermovePointerMove
pointerenterPointerEnter
pointerleavePointerLeave
wheelWheel

Pipeline

flowchart TD
    START["renderToSVG / renderToSVGElement"] --> UPDATE["scene.updateWorldMatrices()"]
    UPDATE --> WALK["Recorrer árbol recursivamente"]
    WALK --> GEO{"¿Geometry?"}
    GEO -->|"Path2D"| PATH["→path"]
    GEO -->|"Box"| RECT["→rect"]
    GEO -->|"Sphere"| CIRCLE["→circle"]
    GEO -->|"Text"| TEXT["→text"]
    GEO -->|"Ninguno"| GROUP["Solo g si tiene hijos"]
    PATH & RECT & CIRCLE & TEXT --> MAT{"¿Material?"}
    MAT -->|"fill/stroke"| STYLE["fill + stroke + opacity"]
    MAT -->|"fillGradient"| GRAD["url(#gradient-id) + defs"]
    MAT -->|"filter/clip/mask"| FILT["url(#filter-id) + defs"]
    MAT -->|"Ninguno"| NONE["fill='none'"]
    STYLE & GRAD & FILT & NONE --> ANIM{"¿Animation?"}
    ANIM -->|"Sí"| ANIMC["animate / animateTransform hijos"]
    ANIM -->|"No"| NOANIM["Sin animación"]
    ANIMC & NOANIM --> TRANSFORM{"¿Transform ≠ identity?"}
    TRANSFORM -->|"Sí"| MATRIX["g transform='matrix(a,b,c,d,e,f)'"]
    TRANSFORM -->|"No"| DIRECT["Elemento directo"]
    MATRIX & DIRECT --> CHILDREN{"¿Hijos?"}
    CHILDREN -->|"Sí"| NEST["Anidar en g"]
    CHILDREN -->|"No"| LEAF["Nodo hoja"]

Soporte de geometrías

GeometríaElemento SVG generado
Path2D<path d="...">
Box<rect> (width × height, depth ignorado)
Sphere<circle> (radio)
Text<text> con font-size, font-family, font-weight, text-anchor, dominant-baseline

Propiedades del material para SVG

CampoTipoEfecto SVGSi ausente
fillColorRGBfill="rgb(R,G,B)"fill="none"
strokeColorRGBstroke="rgb(R,G,B)"Sin stroke
strokeWidthnumberstroke-width="N"1
opacitynumberopacity="N"Sin atributo (opaco)
fillGradientGradientDeffill="url(#id)" + <defs>Usa fill normal
strokeGradientGradientDefstroke="url(#id)" + <defs>Usa stroke normal
filterSvgFilterDeffilter="url(#id)" + <filter> en <defs>Sin filtro
clipPathSvgClipPathDefclip-path="url(#id)" + <clipPath> en <defs>Sin recorte
maskSvgMaskDefmask="url(#id)" + <mask> en <defs>Sin máscara

Transforms y jerarquía

El renderer SVG aplica el localMatrix de cada nodo como atributo transform="matrix(a,b,c,d,e,f)" y genera <g> para representar la jerarquía padre-hijo del scene graph.

const parent = new Node('group');
parent.transform.position = { x: 100, y: 50, z: 0 };

const child = new Node('square');
child.addComponent(createBox(30, 30, 0));
child.addComponent(new Material({ fill: { r: 1, g: 0, b: 0 } }));

parent.add(child);
scene.add(parent);

Genera:

<g transform="matrix(1,0,0,1,100,50)">
  <rect x="-15" y="-15" width="30" height="30" fill="rgb(255, 0, 0)" />
</g>

Gradientes

const circle = new Node('sun');
circle.addComponent(createSphere(80));
circle.addComponent(new Material({
  fillGradient: {
    type: 'radial',
    cx: 0.5, cy: 0.5, r: 0.5,
    stops: [
      { offset: 0, color: { r: 1, g: 1, b: 0 } },
      { offset: 1, color: { r: 1, g: 0.3, b: 0 }, opacity: 0.8 },
    ],
  },
}));

Tipos de gradiente:

TipoDefiniciónElemento SVG
linearLinearGradientDef (x1, y1, x2, y2)<linearGradient>
radialRadialGradientDef (cx, cy, r, fx, fy)<radialGradient>

Texto

const label = new Node('title');
label.addComponent(createText('Oroya Animate', {
  fontSize: 24,
  fontFamily: 'Inter',
  fontWeight: 'bold',
  textAnchor: 'middle',
}));
label.addComponent(new Material({ fill: { r: 0, g: 0, b: 0 } }));
label.transform.position = { x: 200, y: 30, z: 0 };
scene.add(label);

CSS Classes y IDs semánticos

Cada nodo puede tener un cssClass y/o cssId que se emiten como atributos class e id en los elementos SVG generados.

const node = new Node('highlight-box');
node.addComponent(createBox(100, 60, 0));
node.addComponent(new Material({ fill: { r: 1, g: 0.9, b: 0 } }));
node.cssClass = 'highlight animated';
node.cssId = 'main-callout';
scene.add(node);

Genera:

<rect id="main-callout" class="highlight animated" x="-50" y="-30" width="100" height="60" fill="rgb(255, 230, 0)" />

Cuando el nodo tiene hijos o transform, el atributo se aplica al <g> contenedor:

<g id="main-callout" class="highlight animated" transform="matrix(1,0,0,1,50,25)">
  <rect x="-50" y="-30" width="100" height="60" fill="rgb(255, 230, 0)" />
</g>

Serialización: cssClass y cssId se preservan en serialize() / deserialize().

Cámara ortográfica y viewBox

Si la escena contiene un nodo con OrthographicCameraDef, el renderer SVG calcula automáticamente el viewBox a partir del frustum de la cámara. Un viewBox explícito en las opciones tiene prioridad.

const cam = new Node('ortho-cam');
cam.addComponent(new Camera({
  type: CameraType.Orthographic,
  left: -400, right: 400,
  top: -300, bottom: 300,
  near: 0.1, far: 1000,
}));
scene.add(cam);

// viewBox se calcula como "-400 -300 800 600"
const svg = renderToSVG(scene, { width: 800, height: 600 });

La posición de la cámara se aplica como offset al viewBox:

cam.transform.position = { x: 50, y: 25, z: 0 };
// viewBox se calcula como "-350 -275 800 600"

SVG Filters, Clip Paths y Masks

El renderer soporta filtros SVG nativos, clip paths y máscaras a través de campos en MaterialDef.

Blur

const blurred = new Node('soft');
blurred.addComponent(createSphere(40));
blurred.addComponent(new Material({
  fill: { r: 0.5, g: 0.8, b: 1 },
  filter: { effects: [{ type: 'blur', stdDeviation: 3 }] },
}));

Genera:

<defs>
  <filter id="oroya-filter-0">
    <feGaussianBlur stdDeviation="3" />
  </filter>
</defs>
<circle cx="0" cy="0" r="40" fill="rgb(128, 204, 255)" filter="url(#oroya-filter-0)" />

Drop Shadow

new Material({
  fill: { r: 1, g: 0, b: 0 },
  filter: {
    effects: [{
      type: 'dropShadow', dx: 4, dy: 4,
      stdDeviation: 2, floodColor: { r: 0.2, g: 0.2, b: 0.2 }, floodOpacity: 0.6,
    }],
  },
});

Clip Path

new Material({
  fill: { r: 0, g: 1, b: 0 },
  clipPath: {
    path: [
      { command: 'M', args: [0, 0] },
      { command: 'L', args: [100, 0] },
      { command: 'L', args: [50, 100] },
      { command: 'Z', args: [] },
    ],
  },
});

Mask

new Material({
  fill: { r: 0, g: 0, b: 1 },
  mask: {
    path: [
      { command: 'M', args: [0, 0] },
      { command: 'L', args: [80, 0] },
      { command: 'L', args: [80, 80] },
      { command: 'Z', args: [] },
    ],
    fill: { r: 1, g: 1, b: 1 },
    opacity: 0.8,
  },
});

SVG Animaciones nativas

El componente Animation permite agregar animaciones SVG declarativas (<animate> y <animateTransform>) que se ejecutan en el navegador sin JavaScript.

import { Animation } from '@joroya/core';

const circle = new Node('pulse');
circle.addComponent(createSphere(30));
circle.addComponent(new Material({ fill: { r: 1, g: 0, b: 0 } }));
circle.addComponent(new Animation([
  {
    type: 'animate',
    attributeName: 'opacity',
    values: '1;0.3;1',
    dur: '2s',
    repeatCount: 'indefinite',
  },
]));

Genera:

<circle cx="0" cy="0" r="30" fill="rgb(255, 0, 0)">
  <animate attributeName="opacity" values="1;0.3;1" dur="2s" repeatCount="indefinite" />
</circle>

Animaciones de transformación:

new Animation([
  {
    type: 'animateTransform',
    transformType: 'rotate',
    from: '0 50 50',
    to: '360 50 50',
    dur: '4s',
    repeatCount: 'indefinite',
  },
]);

Genera:

<animateTransform attributeName="transform" type="rotate"
  from="0 50 50" to="360 50 50" dur="4s" repeatCount="indefinite" />

fill=“freeze” mantiene el valor final después de que la animación termina, en lugar de revertir.

Nota: Las animaciones nativas solo aplican al renderer SVG. El renderer Three.js las ignora.

Ejemplo completo

const triangle = new Node('triangle');
triangle.addComponent(createPath2D([
  { command: 'M', args: [200, 50] },
  { command: 'L', args: [350, 250] },
  { command: 'L', args: [50, 250] },
  { command: 'Z', args: [] },
]));
triangle.addComponent(new Material({
  fill: { r: 0.2, g: 0.8, b: 0.4 },
  stroke: { r: 0, g: 0, b: 0 },
  strokeWidth: 2,
  opacity: 0.9,
}));
scene.add(triangle);

const svg = renderToSVG(scene, { width: 400, height: 300 });

Casos de uso

CasoVentaja
Exportar a .svgAbrir en Figma, Illustrator, Inkscape
Server-side renderingNode.js sin DOM
Arte generativoPatrones procedurales como vectores
ImpresiónEscalable sin pérdida
Interactividad SVGrenderToSVGElement con event delegation

Comparación entre renderers

Soporte de geometrías

GeometríaThree.jsSVG
Box✅✅ <rect>
Sphere✅✅ <circle>
Path2D❌✅ <path>
Text❌✅ <text>

Soporte de material

PropiedadThree.jsSVG
color✅❌
opacity✅✅
fill❌✅
stroke❌✅
strokeWidth❌✅
fillGradient❌✅
strokeGradient❌✅
filter❌✅
clipPath❌✅
mask❌✅

Soporte de transforms

FeatureThree.jsSVG
Position (translate)✅✅ matrix()
Rotation✅✅ matrix()
Scale✅✅ matrix()
Jerarquía (<g>)✅ Groups✅ <g>

Soporte de componentes especiales

FeatureThree.jsSVG
Camera (Perspective)✅❌
Camera (Orthographic)❌✅ viewBox
Interactive (eventos)✅ Raycaster✅ Event delegation
Animation (SVG nativo)❌✅ <animate> / <animateTransform>
cssClass / cssId❌✅ atributos class / id

Crear un renderer personalizado

El contrato es simple — implementar mount, render y dispose:

import { Scene, ComponentType, Geometry, Material, GeometryPrimitive } from '@joroya/core';

export class Canvas2DRenderer {
  private ctx: CanvasRenderingContext2D;
  private scene: Scene | null = null;

  constructor(canvas: HTMLCanvasElement) {
    this.ctx = canvas.getContext('2d')!;
  }

  mount(scene: Scene): void { this.scene = scene; }

  render(): void {
    if (!this.scene) return;
    this.scene.updateWorldMatrices();
    this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);

    this.scene.traverse(node => {
      const geo = node.getComponent<Geometry>(ComponentType.Geometry);
      if (!geo) return;
      const mat = node.getComponent<Material>(ComponentType.Material);
      const wm = node.transform.worldMatrix;

      this.ctx.save();
      this.ctx.translate(wm[12], wm[13]);

      if (geo.definition.type === GeometryPrimitive.Box) {
        const { width, height } = geo.definition;
        if (mat?.definition.color) {
          const c = mat.definition.color;
          this.ctx.fillStyle = `rgb(${c.r*255},${c.g*255},${c.b*255})`;
        }
        this.ctx.fillRect(-width/2, -height/2, width, height);
      }
      this.ctx.restore();
    });
  }

  dispose(): void { this.scene = null; }
}

Checklist

PasoDescripción
1Crear paquete en packages/renderer-xxx/
2Agregar @joroya/core como dependencia
3Implementar mount() — recorrer árbol y crear objetos
4Implementar render() — sincronizar transforms y dibujar
5Implementar dispose() — liberar recursos
6Documentar geometrías y materiales soportados

@joroya/renderer-canvas2d — Canvas2D

import { renderToCanvas, CanvasRenderer } from '@joroya/renderer-canvas2d';

renderToCanvas(scene, canvas, { width: 800, height: 600, dt: 0 });

const renderer = new CanvasRenderer();
renderer.mount(container, { width: 800, height: 600 });
renderer.render(scene, { width: 800, height: 600, dt: 0 });
renderer.unmount();

Actualización por frame y escenas compartidas

Todos los puntos de entrada oficiales llaman a scene.update(dt) y actualizan las matrices de mundo. Three.js recibe render(dt); SVG y Canvas2D reciben options.dt. El valor por defecto es 1 / 60 de segundo. Generar un string SVG funciona en Node.js, pero modifica la lógica de la escena: no es una función pura.

Para compartir una escena, avanza el tiempo con un solo renderer y usa dt: 0 en los demás:

threeRenderer.render(dt);
const svg = renderToSVG(scene, { width: 800, height: 600, dt: 0 });
renderToCanvas(scene, canvas2D, { width: 800, height: 600, dt: 0 });

dt: 0 también llama a onUpdate(0). Usa callbacks basados en tiempo, sin efectos incondicionales por llamada. Los helpers de instantánea SVG y Canvas2D no enlazan Animator automáticamente: llama a animator.bindToScene(scene) antes de reproducir. renderToSVGElement() genera una instantánea DOM con listeners. Para actualizaciones persistentes usa SvgRenderer; la aplicación controla su bucle RAF.

setSize(width, height) cambia las dimensiones de WebGL y del composer, pero no la proyección de la cámara. Detén tu requestAnimationFrame y elimina tus listeners antes de llamar a dispose().

Three.js conserva la jerarquía padre-hijo y sincroniza matrices locales. Cada frame reconcilia altas, bajas y cambios de padre, limpia la interacción de nodos eliminados y elige una cámara restante o el fallback existente. Eliminación, remount y dispose() idempotente comparten la liberación de geometrías, materiales, texturas, skeletons, audio, mixers, passes y render targets propios. Los hooks liberan los recursos de plugins; las cargas pendientes no pueden adjuntar recursos tras su retirada.

1.2

Capabilities, diagnostics and acceptance describe the current rendering and persistence contracts.