diff --git a/.changeset/alpha-compositing-foundation.md b/.changeset/alpha-compositing-foundation.md new file mode 100644 index 00000000..cd35f4c5 --- /dev/null +++ b/.changeset/alpha-compositing-foundation.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Preserve alpha when copying external input textures and applying effect passes. Source layers use straight-alpha source-over blending; effect passes preserve input coverage and retain their existing alpha-as-strength behavior. Opaque compositions and legacy mask semantics are unchanged. diff --git a/.changeset/displaced-rings.md b/.changeset/displaced-rings.md new file mode 100644 index 00000000..e71cfddf --- /dev/null +++ b/.changeset/displaced-rings.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add Displaced Rings with rings/half-discs, progressive transforms, seeded offsets, and Distort/Cutout output. Support transformed coverage in the editor and runtime so grouped photographic cutouts reveal external layers, with opacity interpolating color and alpha together. diff --git a/.changeset/editor-groups.md b/.changeset/editor-groups.md new file mode 100644 index 00000000..335e783a --- /dev/null +++ b/.changeset/editor-groups.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Support isolated groups in exported ShaderLabConfig layer lists using group layers and parentId references. Evaluate group visibility and opacity before building the render tree, preserving child effect scope in composition and headless rendering. Existing flat configs remain compatible. diff --git a/.changeset/group-drag-drop.md b/.changeset/group-drag-drop.md new file mode 100644 index 00000000..36de81c8 --- /dev/null +++ b/.changeset/group-drag-drop.md @@ -0,0 +1,4 @@ +--- +--- + +Fix editor layer dragging so dropping into or out of a group updates membership and order together. Add destination indicators, subtree movement, cancellation, and edge scrolling. This editor-only change does not require a package release. diff --git a/.changeset/isolated-group-rendering.md b/.changeset/isolated-group-rendering.md new file mode 100644 index 00000000..e4187797 --- /dev/null +++ b/.changeset/isolated-group-rendering.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Add isolated, nested group rendering to the internal renderer frame contract. Group children render against transparent targets before the combined result is blended into the parent. Existing flat public shader configs retain their behavior; editor grouping controls and persisted groups follow separately. diff --git a/.changeset/photographic-cell-paint.md b/.changeset/photographic-cell-paint.md new file mode 100644 index 00000000..a6117133 --- /dev/null +++ b/.changeset/photographic-cell-paint.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add persisted Paint selection to Photographic Cells. Painted coverage shares cell geometry and perimeter outlines with automatic regions, stays anchored in composition space, and reuses its GPU texture while the source animates. diff --git a/.changeset/photographic-cell-regions.md b/.changeset/photographic-cell-regions.md new file mode 100644 index 00000000..f29b20b3 --- /dev/null +++ b/.changeset/photographic-cell-regions.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add coherent Photographic Cells regions with separate region and cell sizes, image-guided or seeded selection, and None/Perimeter/Every Cell outline modes. Preserve saved cell layouts and contour settings while new layers start with connected regions and fine perimeter outlines. diff --git a/.changeset/photographic-cells.md b/.changeset/photographic-cells.md new file mode 100644 index 00000000..a2f86161 --- /dev/null +++ b/.changeset/photographic-cells.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": minor +--- + +Add Photographic Cells with light/dark/random selection, irregular rectangular cells, full-resolution photographic interiors, alpha cutouts, and optional outlines. Support the effect in runtime configs and grouped compositions. Apply user-curated editor defaults to new Displaced Rings layers while preserving saved settings and renderer fallbacks. diff --git a/.changeset/transparent-media-bounds.md b/.changeset/transparent-media-bounds.md new file mode 100644 index 00000000..931e9dab --- /dev/null +++ b/.changeset/transparent-media-bounds.md @@ -0,0 +1,5 @@ +--- +"@basementstudio/shader-lab": patch +--- + +Support transparent borders for contained image and video layers with `params.transparentBounds: true`. Existing runtime configs without the setting retain black borders. diff --git a/.changeset/transparent-text-defaults.md b/.changeset/transparent-text-defaults.md new file mode 100644 index 00000000..5232c2c6 --- /dev/null +++ b/.changeset/transparent-text-defaults.md @@ -0,0 +1,4 @@ +--- +--- + +New editor text uses normal composition over a transparent background. Clarify the background opacity control and preserve the solid fallback for saved text that omits the setting. This editor-only change does not require a package release. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7a0c800..056c78ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - git-chad/shader-lab-v3-plan + - git-chad/v3-* push: branches: - main @@ -50,3 +51,24 @@ jobs: - name: Ensure changeset exists for PR changes if: github.event_name == 'pull_request' run: bunx changeset status --since=origin/${{ github.base_ref }} + + composition: + # Linux Chromium/SwiftShader currently drops its GPU instance during these + # texture-backed shader compilations. Keep the full GPU checks on macOS. + runs-on: macos-14 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bunx playwright install chromium + - run: bun run test:composition + - name: Upload rendered compositions + if: always() + uses: actions/upload-artifact@v4 + with: + name: composition-actuals + path: .context/composition-test/ + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..721d46bb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Shader Lab development instructions + +Before deciding how a shader behaves or looks, open the relevant images listed in [V3-VISUAL-REFERENCES.md](V3-VISUAL-REFERENCES.md). This is an explicit user requirement. Read that guide for reference locations, visual goals, and review criteria; a prose description or passing GPU tests does not replace looking at the images. + +Keep prototype acceptance distinct from completing the visual direction in V3-ROADMAP.md. Preserve the stacked PR workflow; do not merge V3 into main until the plan is complete. diff --git a/V3-ROADMAP.md b/V3-ROADMAP.md index d7a7ac6b..c0079560 100644 --- a/V3-ROADMAP.md +++ b/V3-ROADMAP.md @@ -6,21 +6,33 @@ El orden es una **propuesta de ejecución**; las prioridades de producto son rev ## Flujo de integración y entrega -Todo el trabajo de V3 se entrega mediante PRs pequeños dirigidos a una rama de integración de V3. Un PR principal reúne esa rama y tiene como destino `main`. +Todo el trabajo de V3 se entrega mediante PRs pequeños apilados. Cada nuevo PR parte de la rama del PR anterior y la usa como base. El primer PR de la pila apunta a la rama de integración de V3; un PR principal reúne esa rama y tiene como destino `main`. -**PRs pequeños → rama de integración de V3 → PR principal → `main`.** +**PR nuevo → PR anterior → rama de integración de V3 → PR principal → `main`.** Los PRs pequeños pueden integrarse progresivamente en la rama de V3. Ningún cambio de V3 se integra en `main` hasta completar el alcance obligatorio del plan y su validación global de la fase 7. La fase 8 sigue siendo opcional y no bloquea esa entrega. -La rama de integración es `git-chad/shader-lab-v3-plan`. Todos los PRs pequeños de V3 deben usarla como base; únicamente el PR principal apunta a `main` y permanece en borrador hasta completar la validación obligatoria. +La rama de integración es `git-chad/shader-lab-v3-plan`. La pila comienza con [#151](https://github.com/basementstudio/shader-lab/pull/151), en `git-chad/v3-composition-alpha`, seguido de [#152](https://github.com/basementstudio/shader-lab/pull/152), en `git-chad/v3-transparent-media-bounds`. La base compartida de composición alfa continúa desde esta última rama. Al integrar PRs, actualizar las bases de los descendientes para conservar la pila; únicamente el PR principal apunta a `main` y permanece en borrador hasta completar la validación obligatoria. + +PR principal: [#150 — V3: designer-focused composition and experimental graphics](https://github.com/basementstudio/shader-lab/pull/150). + +Primer paso de la fase 1: [baselines de composición y mapa del canal alfa](tests/composition/README.md). Esta cobertura inicial no cierra la fase ni sustituye la validación visual con las dieciséis referencias elegidas. + +Avance de 1.3: grupos aislados con hasta ocho niveles, controles del editor, reordenamiento de subárboles, plegado, visibilidad, opacidad, duplicación, deshacer, persistencia v7 y exportación de configuración. Las pruebas cubren estado, hidratación real y píxeles guardados/reabiertos. El usuario confirmó la prueba de aislamiento de foto + halftone, opacidad y plegado. La corrección posterior de arrastre permite entrar/salir de grupos con indicadores de destino y actualiza orden y pertenencia en una sola operación; la [lista manual ampliada](tests/composition/GROUPS-MANUAL-QA.md) sigue disponible para la validación global. + +**Prioridades revisadas con el usuario (17 de septiembre de 2026):** texto transparente por defecto (2.1) implementado en #156 y confirmado por el usuario. Anillos desplazados implementados y validados visualmente; el usuario también confirmó la corrección de arrastre entre grupos. Regiones conectadas y contornos de perímetro en Photographic Cells (3.3.1 A+B) confirmados visualmente por el usuario; pintura manual confirmada visualmente por el usuario, con entrada automática a edición al elegir Paint. El usuario reporta alta carga/calor al combinar efectos: evaluar rendimiento durante reproducción y edición de escenas dinámicas en cada entrega. Posponer las máscaras básicas de círculo/rectángulo y su interfaz genérica: no bloquean texto ni la exploración de anillos; revisar cuándo retomarlas al cerrar el alcance de V3. Desarrollar modos de máscara adaptados al lenguaje visual de los efectos seleccionados. Registrar con prioridad baja una revisión de calidad de Blob Tracking y sus posibilidades como máscara (6.3). ## Fase 1 — Resolver la base de composición y proteger proyectos existentes -**Prioridad máxima:** transparencia, grupos y máscaras sostienen la composición. La preparación inicial debe ser breve y servir a la ejecución. +**Prioridad máxima:** transparencia y grupos sostienen la composición. La cobertura alfa necesaria para los modos creativos de máscara se incorpora con los efectos que la usan; la interfaz de máscaras básicas queda aplazada. La preparación inicial debe ser breve y servir a la ejecución. + +### Referencias visuales obligatorias + +Antes de decidir el comportamiento o la apariencia de cualquier shader, abrir las imágenes pertinentes de [V3-VISUAL-REFERENCES.md](V3-VISUAL-REFERENCES.md). El directorio original y la copia local contienen las dieciséis referencias del usuario. Comparar renders con esas imágenes; las pruebas técnicas no sustituyen la revisión visual ni dan por terminada una familia. ### 1.1 Preparar pruebas y referencias mínimas -Reunir las catorce imágenes elegidas y pruebas con fotografías, retratos, objetos, tipografía y video, variando luz y detalle. Guardar escenas existentes y resultados de referencia para comprobar su apariencia desde el primer cambio. +Reunir las dieciséis imágenes elegidas y pruebas con fotografías, retratos, objetos, tipografía y video, variando luz y detalle. Guardar escenas existentes y resultados de referencia para comprobar su apariencia desde el primer cambio. ### 1.2 Comprobar el estado actual y corregir el canal alfa @@ -30,15 +42,23 @@ Permitir regiones vacías junto a contenido totalmente opaco. Bajar la opacidad ### 1.3 Introducir grupos reales con alcance propio -Los grupos y sus máscaras son **requisitos confirmados**, pedidos explícitamente por un colega diseñador. Se propone composición aislada por defecto: procesar los contenidos pertinentes del grupo y luego integrarlo al conjunto. Sus efectos no deben afectar capas externas. Agregar carpetas sobre una cadena global de filtros no resuelve el requisito. +Los grupos con alcance propio son **requisitos confirmados**, pedidos explícitamente por un colega diseñador. Las máscaras de grupo también se solicitaron, pero el usuario decidió posponer la interfaz de máscaras básicas y priorizar modos creativos ligados a efectos (1.4). Se propone composición aislada por defecto: procesar los contenidos pertinentes del grupo y luego integrarlo al conjunto. Sus efectos no deben afectar capas externas. Agregar carpetas sobre una cadena global de filtros no resuelve el requisito. Incluir organización, reordenamiento, plegado, visibilidad, opacidad, deshacer y guardado. Definir la profundidad de anidamiento durante la implementación, sin prometer reproducir todo Photoshop. -### 1.4 Resolver máscaras de capa y grupo +**Ampliación acordada para más adelante — Pass Through:** añadir un modo opcional de grupo que permita a sus efectos procesar las capas inferiores externas, dentro del contexto de composición de su padre. Mantener **Isolated** como valor predeterminado, también para proyectos guardados que no indiquen un modo. Esta ampliación no bloquea las entregas en curso. -Darles asignación clara, inversión, desactivación y bordes consistentes. La máscara de grupo recorta el resultado combinado y revela capas inferiores, sin rellenar de negro ni producir halos negros en bordes suaves. +Ofrecer una elección explícita entre Isolated y Pass Through, con una explicación breve de su alcance. Un grupo con Ink y Displaced Rings sobre una imagen externa debe dejarla intacta en Isolated y procesarla al elegir Pass Through. Las capas superiores y las que estén fuera de un ancestro aislado deben conservar su independencia. Definir el comportamiento de opacidad, mezcla y recorte del grupo antes de implementarlo; no asumir que basta con aplanar sus hijos. -Usar esta pila de prueba, de arriba hacia abajo: +Validar grupos anidados y cambios de modo, visibilidad, reordenamiento, deshacer/rehacer, guardado/reapertura y paridad entre editor, runtime y exportación. Conservar la apariencia de todos los grupos aislados existentes. + +### 1.4 Orientar las máscaras hacia modos creativos por efecto + +**Decisión de producto:** posponer las máscaras básicas de círculo/rectángulo adjuntas a capas o grupos. Priorizar modos adaptados a cada efecto: por ejemplo, anillos o semidiscos desplazados que revelan fotografía y dejan huecos, o regiones detectadas por Blob Tracking que sirven de máscara. Los anillos se exploran en 3.2–3.3; Blob Tracking tiene prioridad baja en 6.3. No asumir que todas las capas necesitan el mismo interruptor genérico de máscara. + +Al incorporar cada modo, definir qué contenido recorta, cómo se asigna y cómo se invierte/desactiva. La cobertura debe revelar capas inferiores, sin rellenos negros ni halos oscuros en bordes suaves. Mantener la composición aislada del grupo y la apariencia de los modos de máscara guardados. La transformación de la imagen y el recorte de su cobertura son comportamientos distintos que deben quedar claros en los controles. + +Conservar esta pila como referencia de alcance para cuando se retomen las máscaras de grupo; no es un requisito del siguiente PR: ```text Texto — fuera del grupo @@ -48,13 +68,13 @@ Grupo «Retrato» — máscara circular Fondo — fuera del grupo ``` -Halftone modifica solo la fotografía; la máscara circular recorta el grupo; texto y fondo quedan fuera de su alcance. Las máscaras de recorte que usan la silueta de otra capa son una extensión sugerida, con alcance inicial pendiente. +Halftone modifica solo la fotografía; la máscara circular recorta el grupo; texto y fondo quedan fuera de su alcance. La asignación arbitraria de la silueta de otra capa sigue como extensión sugerida, con alcance pendiente; no debe confundirse con un modo de máscara propio de un efecto. ### 1.5 Anticipar las decisiones de viabilidad 3D Probar brevemente formatos, materiales, animaciones, conservación de recursos y geometría SVG. Registrar decisiones abiertas para las fases 4 y 5 sin demorar la composición. Una entrada de catálogo o tipo de capa no demuestra soporte completo de modelos. -**Cierre:** huecos transparentes y contenido opaco conviven; máscaras y efectos mantienen su alcance al reordenar, deshacer, guardar, reabrir y exportar. Las escenas anteriores conservan su apariencia y los bordes suaves quedan limpios. +**Cierre de la base:** huecos transparentes y contenido opaco conviven; grupos y efectos mantienen su alcance al reordenar, deshacer, guardar, reabrir y exportar. Las escenas anteriores conservan su apariencia y los bordes suaves quedan limpios. Cada modo creativo de máscara incorporado debe superar esas mismas pruebas en su fase; la interfaz básica aplazada no bloquea el cierre de esta base. ## Fase 2 — Facilitar las piezas tipográficas y pulir las capas actuales @@ -64,7 +84,7 @@ Probar brevemente formatos, materiales, animaciones, conservación de recursos y El usuario reporta texto blanco sobre negro y la necesidad de recurrir a máscaras, Screen o cambiar manualmente el fondo. El requisito es insertar texto visible, con contenido opaco y color editable sobre fondo transparente, sin esos rodeos. Mantener el fondo sólido como una opción deliberada y conservar el uso de texto como máscara cuando se elija expresamente. -La lectura de este checkout confirma que `layers.ts` crea texto en modo máscara y `layer-registry.ts` define fondo negro con alfa 1. `text-pass.ts` ya distingue el alfa del fondo y dibuja el texto opaco; admite color editable y posición mediante anclaje y desplazamiento. Esto orienta la revisión de valores iniciales y composición; no constituye una reproducción visual del problema. +La inspección inicial confirmó que `layers.ts` creaba texto en modo máscara y `layer-registry.ts` definía fondo negro con alfa 1. `text-pass.ts` ya distingue el alfa del fondo y dibuja el texto opaco; admite color editable y posición mediante anclaje y desplazamiento. La corrección debe cambiar los valores de capas nuevas y conservar el fondo sólido de archivos anteriores que omitan ese parámetro. ### 2.2 Explorar selección y arrastre directo en el lienzo @@ -82,6 +102,14 @@ Ajustar parámetros sobre el material de prueba hasta lograr resultados útiles Extender el criterio a los demás efectos. Organizar controles con etiquetas comprensibles, entradas numéricas, deslizadores apropiados y restablecimiento. Aplicarlo también a capas nuevas: valores curados como puntos de partida editables hasta la abstracción extrema. +#### 2.5.1 Colores editables en Threshold + +**Pedido del usuario, pendiente de implementar:** permitir elegir dos colores en la capa **Threshold** existente: **Color oscuro** y **Color claro**, reemplazando la salida fija negro/blanco por una combinación personalizada. Mantener negro y blanco como valores iniciales y como fallback en proyectos anteriores. + +Conservar el funcionamiento del umbral, la inversión y la transparencia; cambiar la paleta solo modifica los colores de salida. Integrar los selectores con deshacer/rehacer, guardado/reapertura y exportación editor/runtime. Evitar un pase de render adicional para esta recoloración, teniendo en cuenta fuentes de video y escenas dinámicas. + +**Cierre:** con los colores iniciales, las escenas existentes se ven iguales; al elegir, por ejemplo, azul y rosa, ambas regiones adoptan esos colores sin desplazar sus límites. La paleta elegida sobrevive al historial, reapertura y exportación. + ### 2.6 Preservar las composiciones guardadas Aplicar los nuevos valores únicamente a capas recién creadas y comparar proyectos anteriores con sus resultados de referencia, incluidos textos con fondo sólido, máscaras y modos de mezcla elegidos previamente. @@ -90,19 +118,19 @@ Aplicar los nuevos valores únicamente a capas recién creadas y comparar proyec ## Fase 3 — Explorar la primera tanda artística e incorporarla al catálogo -**Prioridad propuesta:** anillos, celdas y revelado alterado ofrecen transformaciones diferentes. Esta tanda es una recomendación del asistente, no una selección definitiva del usuario. +**Prioridad revisada:** anillos y celdas ya fueron seleccionados con el usuario. Revelado alterado sigue como familia candidata; su alcance debe confirmarse antes de incorporarla. ### 3.1 Traducir las referencias en criterios visuales Explorar deterioro fotográfico, imágenes sintéticas o escultóricas y geometría, inspirados en afiches experimentales, discos, revistas de música electrónica, Photoshop y diseño de finales de los noventa y los dos mil. Admitir registros claros, oscuros, sobrios y coloridos. -La base principal son las catorce imágenes elegidas, más Lovedance —forma roja sobre fotografía azul, atravesada por luces— y el collage monocromo de foto difusa, curvas técnicas y tipografía precisas. Transformar fotografía con geometría, tono, textura y composición sin limitarse al cyberpunk o una estética retro. Los archivos iniciales fueron exploratorios: solo dos ejemplos interesaron y una página de Paradiso falló. +La base principal son las dieciséis imágenes elegidas, más Lovedance —forma roja sobre fotografía azul, atravesada por luces— y el collage monocromo de foto difusa, curvas técnicas y tipografía precisas. Transformar fotografía con geometría, tono, textura y composición sin limitarse al cyberpunk o una estética retro. Los archivos iniciales fueron exploratorios: solo dos ejemplos interesaron y una página de Paradiso falló. ### 3.2 Probar las tres familias recomendadas | Familia candidata | Resultado y referencias | | --- | --- | -| Anillos desplazados y rotados | Bandas circulares transformables; afiche fotográfico azul y negro con círculos concéntricos desalineados. | +| Anillos desplazados y rotados | Bandas circulares transformables; afiche fotográfico azul y negro con círculos concéntricos desalineados (`16-anillos-desfasados-azules.png`). Incluir en la exploración semidiscos de diferentes tamaños y desplazamientos, con huecos transparentes como modo propio del efecto. | | Recortes por celdas o bloques | Revelar bloques con detalle fotográfico interior y contornos opcionales. El puente presenta siluetas escalonadas, no grandes píxeles planos; el afiche blanco combina regiones de color, bordes celulares y puntos finos. | | Revelado fotográfico alterado | Inversiones tonales parciales, expansión de luces y sombras, neblina y erosión; James Blake verde, retrato azul difuso con anotaciones y retrato gris de apariencia solarizada. | @@ -114,6 +142,69 @@ Exigir el caso de **48 anillos**, aunque la referencia muestre seis u ocho. Expl Permitir destruir completamente el reconocimiento de la imagen. Preparar combinaciones editables, como anillos + dos tintas + erosión, usando componentes disponibles y ampliándolas al incorporar familias posteriores. +Separar la transformación de la fotografía dentro de cada banda del modo que recorta su cobertura. Probar anillos completos y semidiscos, con desplazamiento y rotación, y definir controles útiles para revelar/ocultar regiones. Los huecos deben mostrar el contenido inferior y los efectos deben respetar los límites del grupo. Este trabajo amplía la exploración de anillos existente; no agrega otra familia ni exige implementar primero máscaras geométricas genéricas. + +**Primer prototipo implementado y validado visualmente por el usuario (valores iniciales a cargo del usuario):** Displaced Rings admite 1–128 bandas (48 comprobadas), anillos/semidiscos, centro, radio, distribución, desplazamiento alternado/progresivo/aleatorio con semilla, rotación global y por banda, escala progresiva, separación y bordes suaves. Output distingue Distort y Cutout; dentro de un grupo, Cutout revela las capas externas por los huecos. Editor y runtime comparten el comportamiento, con pruebas de píxeles, hidratación real, deshacer, guardado/reapertura y PNG. El catálogo incorpora una vista previa fotográfica y muestra el efecto primero. Ver [alcance y prueba manual](tests/composition/DISPLACED-RINGS-MANUAL-QA.md). + +Esto no cierra la fase 3: quedan la curaduría visual con las referencias completas, combinaciones con otros efectos, rendimiento con fotografías/video a resoluciones representativas y la selección de las otras familias. El prototipo usa transformaciones progresivas; no incluye controles individuales por anillo ni inversión de cobertura. + +**Valores iniciales de anillos elegidos por el usuario:** las capas nuevas usan 22 semidiscos, radio 2, rotación por anillo de 45°, desplazamiento y separación 0, con Output en Distort. Mantener los parámetros de escenas existentes y los valores anteriores cuando falten campos en archivos guardados. + +**Primer prototipo de celdas aceptado como base útil por el usuario; corregido el exceso de grosor de contornos al cambiar Gap:** Photographic Cells selecciona bloques rectangulares según zonas claras, oscuras o azar con semilla. Conserva el detalle fotográfico interior y permite ajustar tamaño, proporción, irregularidad de filas, separación, bordes suaves, inversión y contornos. Cutout revela las capas externas al grupo; Keep Image conserva la fotografía y superpone únicamente los contornos seleccionados. Incluye catálogo, editor/runtime, guardado/reapertura y PNG. Ver [alcance y prueba manual](tests/composition/PHOTOGRAPHIC-CELLS-MANUAL-QA.md). No cierra la familia: el usuario señala que todavía no alcanza las referencias. Quedan regiones fotográficas conectadas con siluetas escalonadas y contornos de perímetro (puente, `11-puente-recortes-geometricos.png`), regiones amplias de color con bordes celulares y puntos finos (afiche blanco, `05-recortes-celdas-color.png`), curaduría con las imágenes abiertas y validación temporal/de rendimiento con video. La entrega A+B detallada abajo añade regiones conectadas y contornos sin divisiones internas; el usuario confirmó su utilidad visual con una composición propia de águila, anillos, recortes y trama de puntos. Combinar capas para color/tramas cuando resulte más simple. La selección dirigida mediante Paint ya está implementada y aceptada; el tratamiento editorial completo sigue pendiente. + +#### 3.3.1 Evolucionar Photographic Cells hacia regiones y revelado dirigido + +**Dirección acordada con el usuario; A+B+C implementados y confirmados visualmente por el usuario.** La capa incorpora regiones automáticas, contornos de perímetro y pintura manual como otra fuente de selección, útil aunque el modo automático funcione bien. Mantener pocos controles y reutilizar la geometría, transparencia y composición existentes. + +**Objetivo visual:** abrir y comparar `11-puente-recortes-geometricos.png` y `05-recortes-celdas-color.png` antes de decidir apariencia o valores iniciales. En el puente, conservar fotografía reconocible dentro de una silueta conectada con escalones y un contorno fino de color. En el afiche blanco, formar regiones amplias de color con bordes celulares, pequeños fragmentos alrededor y una trama mucho más fina que las celdas. + +**Estado de A+B:** Regions utiliza un campo espacial coherente con semilla o tonos interpolados de la imagen; Region Size controla los parches y Cell Size los escalones. Outline ofrece None / Perimeter / Every Cell, con contornos alrededor de huecos y sin aristas internas a Gap 0, incluidas filas irregulares. Los valores iniciales nuevos favorecen regiones conectadas; hidratación y runtime conservan la apariencia de configuraciones anteriores. Se revisaron renders fotográficos sobre blanco y una combinación exploratoria con halftone. Pintura, Edge Scatter y cierre del tratamiento del afiche siguen pendientes. Ver [prueba manual, cobertura y límites de rendimiento](tests/composition/PHOTOGRAPHIC-CELLS-MANUAL-QA.md). + +**A. Regiones automáticas — primera entrega** + +- Añadir un modo **Regions** que seleccione conjuntos de celdas vecinas y produzca parches continuos. Explorar selección guiada por la imagen y patrones espaciales coherentes con semilla; conservar los modos actuales de selección por celda. +- Separar **Region Size**, que determina la escala de los parches, de **Cell Size**, que determina el tamaño de los escalones del borde. Mantener detalle fotográfico completo dentro de cada región. +- Reutilizar Threshold, inversión y Seed cuando sean pertinentes; mostrar solo los controles relevantes al modo elegido. A Gap 0, las celdas seleccionadas contiguas deben formar una superficie continua. +- Separar internamente la selección de regiones de la generación de celdas y contornos. Tanto la selección automática como la pintura posterior deben alimentar el mismo resultado geométrico. +- Evaluar los resultados con varias fotografías y regiones de color. La selección tonal o procedural no promete identificar sujetos: aislar exactamente el puente requiere una selección dirigida o trabajo posterior de detección. Si el automático no resulta útil, documentar el límite y priorizar pintura sin bloquear toda la mejora. + +**B. Contornos integrados en Photographic Cells — junto con regiones** + +- Mantener el contorno dentro de este efecto: necesita conocer la selección y la vecindad de sus celdas. Un efecto genérico para delinear imágenes, texto o grupos queda como posibilidad futura fuera de esta entrega. +- Controles: **Outline → None / Perimeter / Every Cell**, **Width** y **Color**. Perimeter sigue la silueta revelada; Every Cell conserva la apariencia actual de contornos por celda. +- En Perimeter, eliminar aristas compartidas entre celdas seleccionadas cuando formen una región continua. Delinear también los límites de huecos reales dentro de la región. Comprobar uniones, esquinas y filas irregulares sin costuras internas. +- Definir y comprobar el comportamiento con Gap positivo sobre la cobertura resultante: las separaciones reales pueden crear contornos, sin inflar el grosor. Conservar la corrección reciente de antialiasing, el alfa de la fuente y el comportamiento de Cutout / Keep Image. +- Aplicar los mismos modos de contorno a regiones automáticas y pintadas. Mantener la apariencia de proyectos anteriores: al faltar el nuevo selector, un contorno existente conserva Every Cell y un ancho cero sigue sin dibujar contorno. + +**C. Pintura manual — implementada y confirmada visualmente por el usuario** + +Implementación: Layout Paint entra automáticamente a edición; Done permite salir y Edit Paint retomarla. Reveal/Erase, Brush Size, Clear reversible, Edit/Done, guía tenue temporal y un trazo por acción de historial. Cobertura compacta persistida en parámetros y runtime, textura GPU reutilizada sin reconstruirla por cambios del video. Mantiene el espacio de la grilla al hacer zoom, desplazar o redimensionar; cambiar Cell Size no borra la pintura. Referencia revisada: 11 (puente). [Prueba y límites](tests/composition/CELL-PAINT-MANUAL-QA.md). No cerrar D ni asumir seguimiento del sujeto. + +- Añadir **Paint** como modo de selección de la capa. El usuario pinta dónde quiere revelar la fotografía; la geometría convierte esa área en bloques escalonados. Cambiar Cell Size ajusta la fidelidad al trazo sin borrar lo pintado. +- Interfaz mínima: **Reveal / Erase**, **Brush Size** y **Clear**, con una acción explícita para entrar y salir de pintura. Mostrar la huella del pincel y evitar conflictos con selección, desplazamiento y zoom del lienzo. +- Mantener la pintura alineada en coordenadas de composición al cambiar zoom, tamaño de vista o resolución de exportación. La pintura representa cobertura de esta capa; no incorpora seguimiento automático del sujeto en video. +- Un trazo debe poder deshacerse/rehacerse como una sola acción; Clear también debe ser reversible. Conservar lo pintado al cambiar temporalmente de modo y definir su persistencia en duplicación, guardado/reapertura e hidratación real del editor. +- Incluir la selección pintada en el archivo de proyecto y en la configuración/recursos exportados al runtime. Vista previa, PNG y render de video deben reproducirla sin depender del estado temporal del editor. +- Esta herramienta es un modo de revelado propio de Photographic Cells. No reactiva el trabajo pospuesto de máscaras geométricas genéricas ni exige la revisión de Blob Tracking. + +**D. Refinamientos visuales a evaluar después** + +- **Edge Scatter:** probar un único control que fragmente celdas cerca del perímetro, preservando las regiones principales. Comparar con los pequeños cuadrados alrededor de las manchas del afiche blanco antes de decidir su incorporación. +- **Puntos finos:** probar primero una composición con la capa de halftone existente, manteniendo una escala de trama menor que Cell Size. Evaluar el orden y el alcance del grupo para que la trama respete la región revelada. Añadir controles propios solo si esa combinación no alcanza un resultado útil. +- Color, anotaciones técnicas y tipografía pueden venir de otras capas. Preparar composiciones editables que demuestren el conjunto sin recargar este efecto. + +**Orden y aceptación:** entregar A+B primero en un PR apilado; revisar renders y obtener feedback del usuario. Continuar con C en una entrega posterior; evaluar D con esas herramientas disponibles. No considerar implementada ninguna mejora por haberla documentado. Mantener todos los PR dentro de la pila V3 hasta completar el plan. + +**Criterios de cierre de esta mejora:** + +- Una región de varias celdas a Gap 0 muestra fotografía continua, contorno exterior fino y ausencia de divisiones internas en Perimeter; Every Cell reproduce las divisiones deliberadamente. Comprobar regiones separadas, huecos y filas irregulares. +- Region Size cambia la escala de los parches y Cell Size cambia los escalones sin convertir el interior en colores planos. Comparar capturas con las referencias 05 y 11, incluyendo una composición sobre blanco con amplio espacio vacío. +- En Paint, poder revelar un detalle elegido, borrar parte, cambiar Cell Size y zoom, deshacer/rehacer, guardar/reabrir y exportar conservando el área elegida y sus contornos. +- Mantener aislamiento de grupos, transparencia de la fuente, ancho de contorno estable al variar Gap, compatibilidad de proyectos anteriores y equivalencia editor/runtime. Ampliar pruebas de GPU, hidratación mediante `applyLabProjectFile`, historial y exportación según cada entrega. +- Medir respuesta interactiva con fotografía y video a resoluciones representativas; comprobar estabilidad temporal de regiones automáticas. Documentar límites junto a las capturas revisadas. Pasar `bun run check` y `bun run test:composition` al implementar; la revisión visual del usuario sigue siendo necesaria. + +**Idea para ampliar anillos, todavía por elegir:** probar una única familia de formas concéntricas (círculo, triángulo y cuadrado) reutilizando conteo, rotación, desplazamiento y separación. Separar la forma base del corte completo/mitad permitiría extender los semidiscos sin multiplicar controles. No implementar esta ampliación en el prototipo de celdas; presentar la propuesta al usuario primero. + ### 3.4 Reorganizar el catálogo junto con cada incorporación Dar protagonismo a los nuevos efectos y menor prominencia a los actuales, manteniéndolos accesibles. «Legacy» es una denominación informal, no una deprecación. El nombre público está abierto; **Essentials** fue una sugerencia no confirmada. Acompañar cada alta posterior con esta organización. @@ -170,7 +261,13 @@ Derivar geometría de los contornos y ofrecer profundidad de extrusión, bisel, Elegir según resultados visuales y viabilidad, aplicar los criterios de controles y valores iniciales, actualizar el catálogo en cada incorporación y ampliar las combinaciones editables. -**Cierre:** las familias seleccionadas pasan sus pruebas visuales, de libertad y composición; las candidatas pospuestas quedan identificadas sin presentarlas como entregas prometidas. +### 6.3 Revisar Blob Tracking y sus modos de máscara — prioridad baja + +Pedido del usuario: dedicar una revisión de calidad a Blob Tracking porque tiene potencial visual desaprovechado. Comparar sus resultados actuales sobre fotografía y video; revisar detección, estabilidad temporal, contornos, valores iniciales y claridad de controles antes de elegir cambios. No asumir que necesita una reescritura completa. + +Explorar modos de máscara adaptados a sus regiones detectadas y al movimiento, distinguiendo visualización de la máscara, aplicación de un efecto dentro de las regiones y recorte real de la fotografía. Validar inversión/desactivación, bordes, cobertura alfa, grupos, guardado, reapertura y exportación. Conservar los resultados de escenas anteriores. El alcance concreto se decide tras esa revisión; esta tarea no bloquea texto ni la exploración inicial de anillos. + +**Cierre:** las familias seleccionadas pasan sus pruebas visuales, de libertad y composición; las candidatas pospuestas quedan identificadas sin presentarlas como entregas prometidas. Registrar el resultado de la revisión de Blob Tracking y qué mejoras o modos se integran o se aplazan. ## Fase 7 — Verificar el conjunto y cerrar V3 @@ -182,6 +279,15 @@ Recorrer inserción, edición extrema, combinaciones, catálogo, grupos, máscar ### 7.2 Verificar rendimiento y exportación +**Requisito transversal confirmado por el usuario (17 de septiembre):** el editor debe mantener una interacción y reproducción útiles en escenas dinámicas con video, efectos animados y, al incorporarse, elementos 3D. El usuario reportó alta carga/calor con los nuevos efectos; aún no se atribuyó el coste a una capa concreta. Optimizar escenas estáticas o evitar renders en pausa es solo una parte del trabajo. + +**Base inicial:** benchmark acotado con video decodificado, Rings/Cells por separado y combinados, disponible en `tests/composition/effect-video-performance.mjs`. SwiftShader identifica Rings como el coste mayor en ese entorno; no sustituye medir la GPU nativa ni demuestra que el calor del usuario esté resuelto. Paint reutiliza su textura y verifica que las fuentes dinámicas siguen actualizándose. + +- Medir Rings y Cells por separado y combinados con video en reproducción, más la pila completa; extender los mismos casos a modelos/animación 3D cuando estén disponibles. Registrar GPU/navegador, resolución real, escala de píxeles, parámetros y número de capas. Los tiempos de SwiftShader no sustituyen la validación en hardware real. +- Evaluar tiempos de frame sostenidos, variaciones/picos, respuesta al editar controles y uso de memoria bajo movimiento continuo. Conservar un caso visual representativo del trabajo del usuario para comparar optimizaciones; una captura PNG no permite reconstruir sus parámetros ni diagnosticar por sí sola el origen de la carga. +- Priorizar menos trabajo y muestreos repetidos por píxel, reutilización de recursos y ausencia de lecturas GPU→CPU innecesarias por frame. Cualquier caché debe invalidarse correctamente al cambiar el video, la animación, los parámetros, la cámara/escena 3D o la resolución. +- Mantener fidelidad visual, transparencia, orden de composición y sincronización temporal. Si se necesita una calidad de previsualización ajustable, distinguirla claramente de la calidad de exportación y verificar ambas rutas. Incorporar mediciones comparables a cada ampliación artística, sin esperar al cierre de V3. + Probar casos representativos con capas, video y 3D. Comparar vista previa y exportación, incluidos tiempos de animación, alcance de efectos y recursos conservados. Comprobar transparencia en formatos que la admitan y documentar límites observados de rendimiento y compatibilidad. **Cierre:** verificar los criterios del alcance seleccionado en las fases 1–7, resolver problemas que impidan cumplirlos y distinguir funciones entregadas de exploraciones futuras. La grilla de la fase 8 no es condición para cerrar V3. diff --git a/V3-VISUAL-REFERENCES.md b/V3-VISUAL-REFERENCES.md new file mode 100644 index 00000000..0ffbe4de --- /dev/null +++ b/V3-VISUAL-REFERENCES.md @@ -0,0 +1,45 @@ +# V3 visual references + +These sixteen images are the user's visual direction for Shader Lab V3. Before choosing shader behavior, geometry, defaults, or appearance, **open the relevant reference images**. Compare the resulting render with them. Technical correctness alone does not establish visual completion. + +## Where to find them + +- Original folder: `/Users/tobiasmoccagatta/Desktop/Shader Lab V3 - Referencias/` +- Workspace backup: `.context/v3-references/` (all sixteen originals copied September 17, 2026; gitignored). +- Overview for orientation: `.context/v3-reference-sheet.png` (gitignored). Open individual originals to inspect detail. + +The images are visual references, not application assets. They are not bundled into the product or committed here. In another workspace, use the original folder or copy it into that workspace's `.context/v3-references/`. If neither location exists, request the images instead of guessing from their names. + +## Reference index + +| Image | What to inspect | +| --- | --- | +| `01-lovedance-rojo-azul.png` | Bold geometry over red/blue photography, blurred imagery, layered type. | +| `02-collage-editorial-monocromo.png` | Fragmented monochrome photo, circular geometry, dense type and technical lines. | +| `03-rana-falso-color.png` | Selective false color, dark negative space, delicate tracking annotations. | +| `04-trama-puntos-halos.png` | Fine regular dots, luminous cross-shaped halos, optical contrast. | +| `05-recortes-celdas-color.png` | Connected color regions with stepped cellular boundaries, fine dot texture, sparse technical linework on white. | +| `06-james-blake-fotografia-difusa.png` | Washed green photography, diffuse light and grain, drifting photographic forms. | +| `07-industrial-negro-naranja.png` | Burned orange/yellow highlights, deep black, layered industrial photo and type. | +| `08-estrella-halos-color.png` | Soft multicolor contours and halos around a geometric star. | +| `09-collage-tipografia-tramas.png` | Oversized layered type, photographic fragments, print texture and vivid accents. | +| `10-retrato-azul-anotaciones.png` | Diffuse false-color portrait, washed ground, fine annotations and diagram details. | +| `11-puente-recortes-geometricos.png` | Connected stepped photographic silhouettes, thin perimeter contours, generous white negative space and pink linework. | +| `12-retrato-tonos-invertidos.png` | Partial tonal inversions, solarized photographic detail and washed contrast. | +| `13-arrastre-escaner-rojo.png` | Horizontal scanner drag, torn bands, red/black/white distortion. | +| `14-rostro-rosa-erosionado.png` | Eroded photographic reveal, fine grain, pink staining and white negative space. | +| `15-relieve-grabado-plateado.png` | Fine engraved relief, metallic tonal structure and directional edges. | +| `16-anillos-desfasados-azules.png` | Rotated/offset concentric photographic bands, electric blue/black contrast and layered type. | + +## Photographic Cells: accepted prototype, unfinished visual direction + +The user considers the first layer useful enough; the Gap/outline inflation has been corrected. This does **not** mean it achieves references 05 and 11. + +- The bridge is one photographic subject revealed through connected, stepped regions. Its contours follow the region perimeter; it is not a brick grid with every cell individually outlined. +- The white poster combines broad flowing color regions, small stepped edge fragments, fine dots, and delicate technical marks. Large randomly selected rectangular cells alone do not reproduce that appearance. +- Approved next direction: automatic connected regions and integrated None / Perimeter / Every Cell outlines first, followed by a Paint selection mode with Reveal / Erase, Brush Size, Clear, and undo. Preserve photographic detail and share geometry/outline behavior between automatic and painted selection. +- Region Size controls the broad patches; Cell Size controls their stepped boundaries. Painting should remain useful independently of automatic selection, without implying subject detection. +- Evaluate Edge Scatter later. Try existing halftone and color layers for the fine dot treatment before adding more controls. The detailed sequence and acceptance criteria are in roadmap section 3.3.1; A+B (automatic regions and outline modes) is implemented and visually accepted by the user; Paint is implemented and visually accepted by the user; the later refinements remain planned. +- Use these images for side-by-side visual review before marking the cell family complete. The current smooth field produces stepped patches and perimeter contours, but does not isolate a chosen subject. Directed painting is implemented and accepted; curation of the fine dot/color treatment remains a follow-up. + +For every shader slice, note the reference filenames used, which visible qualities it achieves, and which remain follow-up work. Keep the experimental editorial, analog photographic, and acid-graphics direction visible in actual renders. diff --git a/bun.lock b/bun.lock index 5860ee77..0b577af4 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "babel-plugin-react-compiler": "1.0.0", "cross-env": "^10.1.0", "drizzle-kit": "^0.31.10", + "playwright": "^1.63.0", "postcss-preset-env": "^10.6.1", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", @@ -58,7 +59,7 @@ }, "packages/shader-lab-mcp": { "name": "@basementstudio/shader-lab-mcp", - "version": "0.1.0", + "version": "0.1.1", "bin": { "shader-lab-mcp": "dist/stdio.js", }, @@ -78,7 +79,7 @@ }, "packages/shader-lab-react": { "name": "@basementstudio/shader-lab", - "version": "3.0.0", + "version": "3.0.2", "dependencies": { "typescript": "^5.9.3", }, @@ -1912,6 +1913,10 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss-attribute-case-insensitive": ["postcss-attribute-case-insensitive@7.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.4" } }, "sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw=="], diff --git a/package.json b/package.json index fc2897ef..efc1b5be 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "version-canary": "changeset version --snapshot canary", "version-packages": "changeset version", "start": "next start", + "test:composition": "bun tests/composition/run.mjs", "typecheck": "tsgo --noEmit", "typecheck:runtime": "bun run --cwd packages/shader-lab-react typecheck", "typecheck:compare": "bun run typecheck:tsc && bun run typecheck", @@ -84,6 +85,7 @@ "babel-plugin-react-compiler": "1.0.0", "cross-env": "^10.1.0", "drizzle-kit": "^0.31.10", + "playwright": "^1.63.0", "postcss-preset-env": "^10.6.1", "tailwindcss": "^4.1.18", "typescript": "^5.9.3" diff --git a/packages/shader-lab-react/README.md b/packages/shader-lab-react/README.md index 3136c719..4e0ed564 100644 --- a/packages/shader-lab-react/README.md +++ b/packages/shader-lab-react/README.md @@ -40,6 +40,14 @@ Supported effect layers include ASCII, blob tracking, CRT, directional blur, dit The blob-tracking layer detects moving (or bright) regions of the content below and frames them with CCTV-style shapes, labels, connecting lines, and trails. One inner effect can render inside the detected shapes via `params.innerEffectType` (any pass-backed effect type) and `params.innerEffectParams` (a JSON string of that effect's parameter overrides; see the `ShaderLabBlobInnerEffect` type). Set `params.outputMode` to `"mask"` to emit white-on-black fills for use with `compositeMode: "mask"`. +## Isolated groups + +Exported configs can include layers with `kind: "group"`, `type: "group"`, and `params: {}`. Children reference the group's ID through `parentId`. The flat array uses top-first, depth-first order: group first, then all of its descendants, then the next sibling. Missing or null `parentId` places a layer at the scene root. IDs must be unique and nesting is limited to eight groups. + +For example, the order `[title, portraitGroup, halftone, photo, background]` with `halftone.parentId` and `photo.parentId` set to `portraitGroup.id` confines halftone to the photo. Each group starts transparent, processes its contents, and then applies its own opacity and blend mode to the result. Hiding a group hides its whole subtree without changing child visibility. Group opacity/visibility can use ordinary layer timeline tracks. + +Groups use `compositeMode: "filter"`, `hue: 0`, and `saturation: 1`; coverage masks and group color adjustments are not exposed in this iteration. Group targets currently cost about 32 MiB per group at 1080p. Existing flat configs continue to work without any new fields. + ## Fonts The ASCII and text layers resolve font families at runtime by reading CSS @@ -347,6 +355,13 @@ export function PostProcessingExample({ - Keep the postprocessing `width`/`height` and `postprocessing.resize(...)` in sync with the input render target size, especially for screen-space effects like dithering, pixelation, and ASCII - `postprocessing.texture` always points to the latest output texture +External input textures and output textures use straight (unpremultiplied) RGB +and alpha. The texture path preserves input coverage through effect passes; +effect output alpha controls effect strength. Source layers use source-over +composition. Convert premultiplied input to straight alpha before passing it in. +Legacy mask layers still use their existing RGB-darkening behavior, and the +canvas composition background remains opaque. + ## `useShaderLab` ```ts @@ -653,6 +668,11 @@ export const sketch = Fn(() => { ## Included Runtime Support +Image and video layers in Contain mode support `params.transparentBounds: true` +to reveal the content beneath their empty borders. Omit the setting or use +`false` to retain black borders. This option does not affect Cover mode or make +the composition's canvas background transparent. + - Gradient - Text - Custom shader (source and effect modes) diff --git a/packages/shader-lab-react/src/ambient/three-tsl.d.ts b/packages/shader-lab-react/src/ambient/three-tsl.d.ts index de8f35c0..20fdde4b 100644 --- a/packages/shader-lab-react/src/ambient/three-tsl.d.ts +++ b/packages/shader-lab-react/src/ambient/three-tsl.d.ts @@ -1,7 +1,8 @@ declare module "three/tsl" { export interface LoopConfig { + name?: string condition?: string - end: number + end: number | TSLNode start: number type: "float" | "int" } @@ -103,16 +104,32 @@ declare module "three/tsl" { export function cross(left: unknown, right: unknown): TSLNode export function div(left: unknown, right: unknown): TSLNode export function dot(left: unknown, right: unknown): TSLNode + export interface ShaderFunction { + (...args: unknown[]): TSLNode & { compute(count: number): unknown } + setLayout(layout: { + name: string + type: string + inputs: { name: string; type: string }[] + }): ShaderFunction + } + export interface ConditionalNode extends TSLNode { + Else(callback: () => unknown): ConditionalNode + } export function Fn( fn: ShaderNodeFn | ((...args: never[]) => unknown), layout?: unknown - ): (...args: unknown[]) => TSLNode & { compute(count: number): unknown } + ): ShaderFunction export function Loop( config: LoopConfig | TSLNode | number, callback: (inputs: LoopInputs) => unknown ): TSLNode export function exp(value: unknown): TSLNode export function fract(value: unknown): TSLNode + export function If( + condition: TSLNode, + callback: () => unknown + ): ConditionalNode + export function int(value?: unknown): TSLNode export function float(value?: unknown): TSLNode export function floor(value: unknown): TSLNode export function mat2( diff --git a/packages/shader-lab-react/src/ambient/three-webgpu.d.ts b/packages/shader-lab-react/src/ambient/three-webgpu.d.ts index 8740a737..122555f4 100644 --- a/packages/shader-lab-react/src/ambient/three-webgpu.d.ts +++ b/packages/shader-lab-react/src/ambient/three-webgpu.d.ts @@ -4,6 +4,7 @@ declare module "three/webgpu" { import type { Box3, Camera, + Color, ColorRepresentation, Material, Scene, @@ -47,6 +48,8 @@ declare module "three/webgpu" { // biome-ignore lint/suspicious/noExplicitAny: compute node type from Fn().compute() is opaque computeAsync(computeNodes: any): Promise clear(): void + getClearAlpha(): number + getClearColor(target: Color): Color copyTextureToTexture( srcTexture: Texture, dstTexture: Texture, diff --git a/packages/shader-lab-react/src/renderer/blend-modes.ts b/packages/shader-lab-react/src/renderer/blend-modes.ts index 9a67bc74..50e4ccc8 100644 --- a/packages/shader-lab-react/src/renderer/blend-modes.ts +++ b/packages/shader-lab-react/src/renderer/blend-modes.ts @@ -161,6 +161,8 @@ export type MaskNodeConfig = { source: string } +export type CompositionRole = "source" | "effect" | "transform" + export function buildBlendNode( mode: string, base: Node, @@ -168,6 +170,7 @@ export function buildBlendNode( opacity: Node, compositeMode: "filter" | "mask" = "filter", maskConfig?: MaskNodeConfig, + role: CompositionRole = "source", ): Node { const baseRgb = base.rgb const blendRgb = blend.rgb @@ -228,9 +231,50 @@ export function buildBlendNode( } if (compositeMode === "filter") { - return vec4(mix(baseRgb, composited, normalizedOpacity.mul(blendAlpha)), float(1)) + const baseAlpha = float(clamp(base.a, float(0), float(1))) + if (role === "transform") { + // Spatial transformations replace coverage as well as color. Interpolate + // premultiplied pixels so opacity=0 restores input and cutout holes stay clear. + const outputAlpha = mix(baseAlpha, blendAlpha, normalizedOpacity) + const transformedRgb = mix(blendRgb, composited, baseAlpha) + const premultiplied = mix( + baseRgb.mul(baseAlpha), + transformedRgb.mul(blendAlpha), + normalizedOpacity + ) + return vec4( + premultiplied.div( + select(outputAlpha.greaterThan(0), outputAlpha, float(1)) + ), + outputAlpha + ) + } + const amount = normalizedOpacity.mul(blendAlpha) + const filteredRgb = mix(baseRgb, composited, amount) + + if (role === "effect") { + // An effect's output alpha controls its strength, not a second layer of + // coverage. Preserve the input silhouette through repeated filtering. + return vec4(filteredRgb, baseAlpha) + } + + // Straight-alpha source-over, with blending only where source and backdrop + // overlap. See https://www.w3.org/TR/compositing-1/#blending . + const outputAlpha = amount.add(baseAlpha.mul(float(1).sub(amount))) + const sourceRgb = mix(blendRgb, composited, baseAlpha) + const premultipliedRgb = sourceRgb.mul(amount).add( + baseRgb.mul(baseAlpha).mul(float(1).sub(amount)), + ) + // Avoid division by zero without dimming tiny but nonzero coverage. + const denominator = select(outputAlpha.greaterThan(0), outputAlpha, float(1)) + const outputRgb = premultipliedRgb.div(denominator) + // Keep the exact existing expression for opaque backdrops. + return vec4(select(baseAlpha.equal(1), filteredRgb, outputRgb), outputAlpha) } + // Existing mask mode is a saved RGB-darkening operation. Coverage masks + // require an explicit compatibility path and are not introduced here. + const source = maskConfig?.source ?? "luminance" let maskValue: Node switch (source) { diff --git a/packages/shader-lab-react/src/renderer/cell-paint-mask.ts b/packages/shader-lab-react/src/renderer/cell-paint-mask.ts new file mode 100644 index 00000000..d782e519 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/cell-paint-mask.ts @@ -0,0 +1,53 @@ +/** Binary coverage in centered, shorter-edge composition units. Versioned and + * bounded so projects/history need no external assets or growing stroke lists. */ +export const CELL_PAINT_SIZE = 512 +const PIXELS = CELL_PAINT_SIZE * CELL_PAINT_SIZE +const BYTES = PIXELS / 8 +export type CellPaintMask = { data: Uint8Array; width: number; height: number } +export function emptyCellPaintMask(width = 1, height = 1): CellPaintMask { + return { data: new Uint8Array(PIXELS), width, height } +} +export function decodeCellPaintMask(value: unknown): CellPaintMask { + if (typeof value !== "string" || value.length > 44000) + return emptyCellPaintMask() + const parts = value.split(":") + const width = Number(parts[1]) + const height = Number(parts[2]) + if ( + parts.length !== 4 || + parts[0] !== "pc1" || + !Number.isFinite(width) || + !Number.isFinite(height) || + width < 1 || + height < 1 || + width > 100 || + height > 100 || + (parts[3] ?? "").length !== Math.ceil(BYTES / 3) * 4 + ) + return emptyCellPaintMask() + try { + const packed = atob(parts[3] ?? "") + if (packed.length !== BYTES) return emptyCellPaintMask() + const mask = emptyCellPaintMask(width, height) + for (let i = 0; i < PIXELS; i++) + mask.data[i] = packed.charCodeAt(i >> 3) & (1 << (i & 7)) ? 255 : 0 + return mask + } catch { + return emptyCellPaintMask() + } +} +export function encodeCellPaintMask(mask: CellPaintMask): string { + const packed = new Uint8Array(BYTES) + let any = false + for (let i = 0; i < PIXELS; i++) { + if (mask.data[i]) { + packed[i >> 3] = (packed[i >> 3] ?? 0) | (1 << (i & 7)) + any = true + } + } + if (!any) return "" + let binary = "" + for (let i = 0; i < packed.length; i += 4096) + binary += String.fromCharCode(...packed.subarray(i, i + 4096)) + return `pc1:${mask.width}:${mask.height}:${btoa(binary)}` +} diff --git a/packages/shader-lab-react/src/renderer/composition-tree.ts b/packages/shader-lab-react/src/renderer/composition-tree.ts new file mode 100644 index 00000000..c206bc5a --- /dev/null +++ b/packages/shader-lab-react/src/renderer/composition-tree.ts @@ -0,0 +1,71 @@ +/** Internal render tree; editor persistence and public shader configs remain flat. */ +export interface CompositionGroup { + blendMode: string + children: CompositionNode[] + id: string + kind: "group" + opacity: number + visible: boolean +} + +export type CompositionNode = Layer | CompositionGroup + +export const MAX_COMPOSITION_GROUP_DEPTH = 8 + +export function isCompositionGroup( + node: CompositionNode +): node is CompositionGroup { + return ( + typeof node === "object" && + node !== null && + "kind" in node && + node.kind === "group" + ) +} + +/** Validate the entire tree before changing live passes. IDs are globally unique. */ +export function flattenComposition( + nodes: CompositionNode[], + getLayerId: (layer: Layer) => string +): CompositionNode[] { + const result: CompositionNode[] = [] + const ids = new Set() + function visit(entries: CompositionNode[], depth: number): void { + for (const entry of entries) { + const group = isCompositionGroup(entry) + const id = group ? entry.id : getLayerId(entry) + if (ids.has(id)) throw new Error(`Duplicate composition ID: ${id}`) + ids.add(id) + result.push(entry) + if (group) { + if (depth >= MAX_COMPOSITION_GROUP_DEPTH) { + throw new Error( + `Composition groups support at most ${MAX_COMPOSITION_GROUP_DEPTH} levels` + ) + } + visit(entry.children, depth + 1) + } + } + } + visit(nodes, 0) + return result +} + +/** Frames use sidebar order (top first); pipelines paint bottom first at every level. */ +export function reverseComposition( + nodes: CompositionNode[], + depth = 0 +): CompositionNode[] { + if (depth > MAX_COMPOSITION_GROUP_DEPTH) { + throw new Error( + `Composition groups support at most ${MAX_COMPOSITION_GROUP_DEPTH} levels` + ) + } + return [...nodes] + .reverse() + .map((node) => + isCompositionGroup(node) + ? { ...node, children: reverseComposition(node.children, depth + 1) } + : node + ) +} diff --git a/packages/shader-lab-react/src/renderer/contracts.ts b/packages/shader-lab-react/src/renderer/contracts.ts index bc80f735..8b077a23 100644 --- a/packages/shader-lab-react/src/renderer/contracts.ts +++ b/packages/shader-lab-react/src/renderer/contracts.ts @@ -1,6 +1,8 @@ +import { buildCompositionTree } from "./layer-hierarchy" import { createRuntimeClock } from "../runtime-clock" import { resolveEvaluatedLayers } from "../timeline" import type { ShaderLabConfig, ShaderLabLayerConfig } from "../types" +import type { CompositionNode } from "./composition-tree" export interface RendererSize { height: number @@ -14,9 +16,13 @@ export interface ProjectClock { time: number } +export type RenderableLayerConfig = ShaderLabLayerConfig & { + kind: "source" | "effect" +} + export interface RendererFrame { clock: ProjectClock - layers: ShaderLabLayerConfig[] + layers: CompositionNode[] logicalSize: RendererSize outputSize: RendererSize pixelRatio: number @@ -49,11 +55,14 @@ export function buildRendererFrame( config.layers, config.timeline.tracks, time - ).filter((layer) => layer.visible) + ) return { clock: createRuntimeClock(config.timeline, time, delta), - layers, + layers: buildCompositionTree( + layers, + (layer) => layer as RenderableLayerConfig + ), logicalSize: options?.logicalSize ?? config.composition ?? viewportSize, outputSize: viewportSize, pixelRatio, diff --git a/packages/shader-lab-react/src/renderer/create-headless-renderer.ts b/packages/shader-lab-react/src/renderer/create-headless-renderer.ts index 43a61156..194ba660 100644 --- a/packages/shader-lab-react/src/renderer/create-headless-renderer.ts +++ b/packages/shader-lab-react/src/renderer/create-headless-renderer.ts @@ -1,6 +1,7 @@ import * as THREE from "three/webgpu" import type { RendererFrame, RendererSize } from "./contracts" import { PipelineManager } from "./pipeline-manager" +import { reverseComposition } from "./composition-tree" export interface HeadlessRenderer { dispose(): void @@ -79,7 +80,7 @@ export function createHeadlessRenderer( } pipeline.updateLogicalSize(frame.logicalSize) - pipeline.syncLayers([...frame.layers].reverse()) + pipeline.syncLayers(reverseComposition(frame.layers)) if (ownsRenderer) { return pipeline.renderToTexture( diff --git a/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts b/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts index d8b1b088..d99b4071 100644 --- a/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts +++ b/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts @@ -1,6 +1,7 @@ import * as THREE from "three/webgpu" import type { RendererFrame, RuntimeRenderer } from "./contracts" import { PipelineManager } from "./pipeline-manager" +import { reverseComposition } from "./composition-tree" export function browserSupportsWebGPU(): boolean { return typeof navigator !== "undefined" && "gpu" in navigator @@ -60,7 +61,7 @@ export async function createWebGPURenderer( } pipeline.updateLogicalSize(frame.logicalSize) - pipeline.syncLayers([...frame.layers].reverse()) + pipeline.syncLayers(reverseComposition(frame.layers)) return pipeline.render(frame.clock.time, frame.clock.delta) }, diff --git a/packages/shader-lab-react/src/renderer/displaced-rings-pass.ts b/packages/shader-lab-react/src/renderer/displaced-rings-pass.ts new file mode 100644 index 00000000..497668a4 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/displaced-rings-pass.ts @@ -0,0 +1,276 @@ +import * as THREE from "three/webgpu" +import { + cos, + float, + Fn, + fract, + If, + int, + length, + Loop, + max, + min, + mix, + pow, + select, + sin, + smoothstep, + step, + texture, + type TSLNode, + uniform, + uv, + vec2, + vec3, + vec4, +} from "three/tsl" +import { PassNode } from "./pass-node" +import type { LayerParameterValues } from "../types/editor" + +type Node = TSLNode + +function number( + value: unknown, + fallback: number, + low: number, + high: number +): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(low, Math.min(high, value)) + : fallback +} + +/** Inverse-map each photographic band; overlap paints outer bands before inner ones. */ +export class DisplacedRingsPass extends PassNode { + private readonly count = uniform(8) + private readonly radius = uniform(0.9) + private readonly distribution = uniform(1) + private readonly center = uniform(new THREE.Vector2(0.5, 0.5)) + private readonly offset = uniform(new THREE.Vector2(0.045, 0)) + private readonly rotation = uniform(0) + private readonly rotationStep = uniform((12 * Math.PI) / 180) + private readonly scaleStep = uniform(0) + private readonly gap = uniform(0.04) + private readonly softness = uniform(0) + private readonly halfDiscs = uniform(0) + private readonly cutout = uniform(0) + private readonly pattern = uniform(0) + private readonly seed = uniform(1) + private readonly aspect = uniform(new THREE.Vector2(1, 1)) + private readonly resolution = uniform(new THREE.Vector2(1, 1)) + private readonly source: Node + private readonly placeholder: THREE.Texture + + constructor(id: string) { + super(id) + this.placeholder = new THREE.Texture() + this.placeholder.type = THREE.HalfFloatType + this.placeholder.minFilter = THREE.NearestFilter + this.placeholder.magFilter = THREE.NearestFilter + this.placeholder.generateMipmaps = false + this.source = texture(this.placeholder) + this.rebuildEffectNode() + } + + override render( + renderer: THREE.WebGPURenderer, + input: THREE.Texture, + output: THREE.WebGLRenderTarget, + time: number, + delta: number + ): void { + this.source.value = input + super.render(renderer, input, output, time, delta) + } + + override resize(width: number, height: number): void { + ;(this.resolution.value as THREE.Vector2).set( + Math.max(1, width), + Math.max(1, height) + ) + } + + override updateLogicalSize(width: number, height: number): void { + const shorter = Math.max(1, Math.min(width, height)) + ;(this.aspect.value as THREE.Vector2).set( + Math.max(1, width) / shorter, + Math.max(1, height) / shorter + ) + } + + override updateParams(params: LayerParameterValues): void { + this.count.value = Math.round(number(params.count, 8, 1, 128)) + this.radius.value = number(params.radius, 0.9, 0.01, 2) + this.distribution.value = number(params.distribution, 1, 0.25, 3) + const center = Array.isArray(params.center) ? params.center : [0, 0] + ;(this.center.value as THREE.Vector2).set( + 0.5 + number(center[0], 0, -1, 1), + 0.5 - number(center[1], 0, -1, 1) + ) + const offset = Array.isArray(params.offset) ? params.offset : [0.045, 0] + ;(this.offset.value as THREE.Vector2).set( + number(offset[0], 0.045, -1, 1), + -number(offset[1], 0, -1, 1) + ) + this.rotation.value = + (number(params.rotation, 0, -180, 180) * Math.PI) / 180 + this.rotationStep.value = + (number(params.rotationStep, 12, -180, 180) * Math.PI) / 180 + this.scaleStep.value = number(params.scaleStep, 0, -0.9, 3) + this.gap.value = number(params.gap, 0.04, 0, 1) + this.softness.value = number(params.softness, 0, 0, 0.1) + this.halfDiscs.value = params.shape === "half-discs" ? 1 : 0 + this.cutout.value = params.output === "cutout" ? 1 : 0 + this.pattern.value = 0 + if (params.pattern === "progressive") this.pattern.value = 1 + if (params.pattern === "random") this.pattern.value = 2 + this.seed.value = number(params.seed, 1, 0, 1000) + } + + protected override buildEffectNode(): Node { + if (!this.source) return this.inputNode + return Fn(() => { + const screen = vec2(uv().x, float(1).sub(uv().y)) + const point = screen.sub(this.center).mul(this.aspect) + const original = this.inputNode + // Work in premultiplied color only while accumulating soft coverage. + const rgb = select( + this.cutout.greaterThan(0.5), + vec3(0), + original.rgb.mul(original.a) + ).toVar() + const alpha = select( + this.cutout.greaterThan(0.5), + float(0), + original.a + ).toVar() + const edge = max( + max( + this.aspect.x.div(this.resolution.x), + this.aspect.y.div(this.resolution.y) + ).mul(0.75), + this.softness + ) + // Shared boundaries must not apply antialias coverage twice: touching + // concentric bands partition the image without translucent seams. + const sharedEdges = this.gap + .equal(0) + .and(this.softness.equal(0)) + .and(length(this.offset).equal(0)) + .and(this.halfDiscs.equal(0)) + Loop({ start: 0, end: int(this.count), type: "int" }, ({ i }) => { + const index = this.count.sub(1).sub(float(i)) + const progress = index.div(max(this.count.sub(1), 1)) + const alternating = select( + index.mod(2).lessThan(1), + float(-1), + float(1) + ) + const random = fract( + sin(index.mul(127.1).add(this.seed.mul(311.7))).mul(43758.5453) + ) + .mul(2) + .sub(1) + const displacement = select( + this.pattern.greaterThan(1.5), + random, + select( + this.pattern.greaterThan(0.5), + progress, + alternating.mul(progress.mul(0.65).add(0.35)) + ) + ) + const moved = point.sub(this.offset.mul(displacement)) + const angle = this.rotation.add(this.rotationStep.mul(index)) + const c = cos(angle) + const s = sin(angle) + const local = vec2( + moved.x.mul(c).add(moved.y.mul(s)), + moved.y.mul(c).sub(moved.x.mul(s)) + ) + const distance = length(local) + const inner = pow(index.div(this.count), this.distribution).mul( + this.radius + ) + const outer = pow(index.add(1).div(this.count), this.distribution).mul( + this.radius + ) + const inset = outer.sub(inner).mul(this.gap).mul(0.5) + const softOuter = float(1).sub( + smoothstep( + outer.sub(inset).sub(edge), + outer.sub(inset).add(edge), + distance + ) + ) + const insideOuter = select( + sharedEdges.and(index.lessThan(this.count.sub(1))), + float(1).sub(step(outer, distance)), + softOuter + ) + const softInner = select( + index.equal(0), + float(1), + smoothstep( + inner.add(inset).sub(edge), + inner.add(inset).add(edge), + distance + ) + ) + const outsideInner = select( + sharedEdges, + step(inner, distance), + softInner + ) + const half = select( + this.halfDiscs.greaterThan(0.5), + smoothstep(edge.negate(), edge, local.y.negate()), + float(1) + ) + const coverage = insideOuter + .mul(outsideInner) + .mul(half) + .mul(select(this.gap.greaterThanEqual(1), float(0), float(1))) + If(coverage.greaterThan(0), () => { + const scale = max(float(1).add(this.scaleStep.mul(progress)), 0.1) + const sampleUv = local.div(scale).div(this.aspect).add(this.center) + const bounds = step(0, sampleUv.x) + .mul(step(sampleUv.x, 1)) + .mul(step(0, sampleUv.y)) + .mul(step(sampleUv.y, 1)) + // Explicit LOD makes texture sampling valid in per-pixel loop branches. + const sample = this.source.sample(sampleUv).level(0) + const sampleAlpha = sample.a.mul(bounds) + const amount = sampleAlpha.mul(coverage) + const nextRgb = sample.rgb + .mul(amount) + .add(rgb.mul(float(1).sub(amount))) + const nextAlpha = amount.add(alpha.mul(float(1).sub(amount))) + rgb.assign( + select( + this.cutout.greaterThan(0.5), + nextRgb, + mix(rgb, sample.rgb.mul(sampleAlpha), coverage) + ) + ) + alpha.assign( + select( + this.cutout.greaterThan(0.5), + nextAlpha, + mix(alpha, sampleAlpha, coverage) + ) + ) + }) + }) + return vec4( + rgb.div(select(alpha.greaterThan(0), alpha, float(1))), + min(alpha, 1) + ) + })() + } + + override dispose(): void { + this.placeholder.dispose() + super.dispose() + } +} diff --git a/packages/shader-lab-react/src/renderer/group-pass.ts b/packages/shader-lab-react/src/renderer/group-pass.ts new file mode 100644 index 00000000..c91e5b74 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/group-pass.ts @@ -0,0 +1,113 @@ +import { float, texture, type TSLNode, uv, vec2 } from "three/tsl" +import * as THREE from "three/webgpu" +import { PassNode } from "./pass-node" + +export type RenderChildPass = ( + pass: PassNode, + input: THREE.Texture, + output: THREE.WebGLRenderTarget, + time: number, + delta: number, + timelineTime: number +) => boolean + +/** Owns only its intermediate targets; the pipeline owns all child passes. */ +export class GroupPass extends PassNode { + private children: PassNode[] = [] + private readonly rtA: THREE.WebGLRenderTarget + private readonly rtB: THREE.WebGLRenderTarget + private readonly groupInput: TSLNode + private readonly clearColor = new THREE.Color() + + constructor( + id: string, + private readonly isActive: (pass: PassNode) => boolean, + private readonly renderChild: RenderChildPass + ) { + super(id) + const options = { + depthBuffer: false, + stencilBuffer: false, + type: THREE.HalfFloatType, + format: THREE.RGBAFormat, + minFilter: THREE.NearestFilter, + magFilter: THREE.NearestFilter, + generateMipmaps: false, + } + this.rtA = new THREE.WebGLRenderTarget(1, 1, options) + this.rtB = new THREE.WebGLRenderTarget(1, 1, options) + this.groupInput = texture( + this.rtA.texture, + vec2(uv().x, float(1).sub(uv().y)) + ) + this.updateCompositionRole("source") + this.rebuildEffectNode() + } + + setChildren(children: PassNode[]): boolean { + if ( + children.length === this.children.length && + children.every((pass, i) => pass === this.children[i]) + ) + return false + this.children = children + return true + } + + override resize(width: number, height: number): void { + this.rtA.setSize(width, height) + this.rtB.setSize(width, height) + } + + override needsContinuousRender(): boolean { + return this.children.some( + (pass) => this.isActive(pass) && pass.needsContinuousRender() + ) + } + + override render( + renderer: THREE.WebGPURenderer, + inputTexture: THREE.Texture, + outputTarget: THREE.WebGLRenderTarget, + time: number, + delta: number, + timelineTime = time + ): void { + // Never seed a group with the parent image or the opaque scene background. + const alpha = renderer.getClearAlpha() + renderer.getClearColor(this.clearColor) + try { + renderer.setRenderTarget(this.rtA) + renderer.setClearColor(0, 0) + renderer.clear() + } finally { + renderer.setClearColor(this.clearColor, alpha) + } + + let read = this.rtA + let write = this.rtB + for (const pass of this.children) { + if ( + !( + this.isActive(pass) && + this.renderChild(pass, read.texture, write, time, delta, timelineTime) + ) + ) + continue + ;[read, write] = [write, read] + } + this.groupInput.value = read.texture + super.render(renderer, inputTexture, outputTarget, time, delta) + } + + protected override buildEffectNode(): TSLNode { + return this.groupInput ?? this.inputNode + } + + override dispose(): void { + this.rtA.dispose() + this.rtB.dispose() + this.children = [] + super.dispose() + } +} diff --git a/packages/shader-lab-react/src/renderer/layer-hierarchy.ts b/packages/shader-lab-react/src/renderer/layer-hierarchy.ts new file mode 100644 index 00000000..8c4c4b2f --- /dev/null +++ b/packages/shader-lab-react/src/renderer/layer-hierarchy.ts @@ -0,0 +1,71 @@ +import { + type CompositionNode, + MAX_COMPOSITION_GROUP_DEPTH, +} from "./composition-tree" + +export interface HierarchyLayer { + id: string + kind: string + parentId?: string | null | undefined +} + +/** A flat, top-first preorder keeps subtrees contiguous for selection and moves. */ +export function validateLayerHierarchy( + layers: readonly HierarchyLayer[] +): void { + const ids = new Set() + const ancestors: string[] = [] + for (const layer of layers) { + if (ids.has(layer.id)) throw new Error(`Duplicate layer ID: ${layer.id}`) + ids.add(layer.id) + const parent = layer.parentId ?? null + while (ancestors.length && ancestors.at(-1) !== parent) ancestors.pop() + if (parent !== null && ancestors.at(-1) !== parent) { + throw new Error("Group children must follow their parent in layer order.") + } + if (layer.kind === "group") { + if (ancestors.length >= MAX_COMPOSITION_GROUP_DEPTH) { + throw new Error( + `Groups support at most ${MAX_COMPOSITION_GROUP_DEPTH} levels.` + ) + } + ancestors.push(layer.id) + } + } +} + +export function buildCompositionTree< + Layer extends HierarchyLayer & { + blendMode: string + opacity: number + visible: boolean + }, + Leaf, +>(layers: Layer[], leaf: (layer: Layer) => Leaf): CompositionNode[] { + validateLayerHierarchy(layers) + const root: CompositionNode[] = [] + const children = new Map[]>() + const hidden = new Set() + for (const layer of layers) { + if (!layer.visible || (layer.parentId && hidden.has(layer.parentId))) { + hidden.add(layer.id) + continue + } + const target = layer.parentId ? children.get(layer.parentId)! : root + if (layer.kind === "group") { + const nested: CompositionNode[] = [] + target.push({ + id: layer.id, + kind: "group", + blendMode: layer.blendMode, + opacity: layer.opacity, + visible: true, + children: nested, + }) + children.set(layer.id, nested) + } else { + target.push(leaf(layer)) + } + } + return root +} diff --git a/packages/shader-lab-react/src/renderer/media-pass.ts b/packages/shader-lab-react/src/renderer/media-pass.ts index 6296bf00..8cca8590 100644 --- a/packages/shader-lab-react/src/renderer/media-pass.ts +++ b/packages/shader-lab-react/src/renderer/media-pass.ts @@ -22,6 +22,7 @@ type Node = TSLNode export class MediaPass extends PassNode { private readonly canvasAspectUniform: Node private readonly fitModeUniform: Node + private readonly boundsAlphaUniform: Node private readonly offsetXUniform: Node private readonly offsetYUniform: Node private readonly scaleUniform: Node @@ -40,6 +41,7 @@ export class MediaPass extends PassNode { this.placeholder = new THREE.Texture() this.canvasAspectUniform = uniform(1) this.fitModeUniform = uniform(0) + this.boundsAlphaUniform = uniform(1) this.offsetXUniform = uniform(0) this.offsetYUniform = uniform(0) this.scaleUniform = uniform(1) @@ -101,6 +103,7 @@ export class MediaPass extends PassNode { override updateParams(params: LayerParameterValues): void { this.fitModeUniform.value = params.fitMode === "contain" ? 1 : 0 + this.boundsAlphaUniform.value = params.transparentBounds === true ? 0 : 1 this.scaleUniform.value = typeof params.scale === "number" ? 1 / Math.max(params.scale, 0.01) : 1 @@ -180,7 +183,11 @@ export class MediaPass extends PassNode { .and(sampledUv.x.lessThanEqual(1)) .and(sampledUv.y.greaterThanEqual(0)) .and(sampledUv.y.lessThanEqual(1)) - const contained = select(inBounds, this.mediaTextureNode, vec4(0, 0, 0, 1)) + const contained = select( + inBounds, + this.mediaTextureNode, + vec4(0, 0, 0, this.boundsAlphaUniform) + ) return mix(this.mediaTextureNode, contained, useContain) } diff --git a/packages/shader-lab-react/src/renderer/pass-node.ts b/packages/shader-lab-react/src/renderer/pass-node.ts index 6e4b7ef9..feae9e6a 100644 --- a/packages/shader-lab-react/src/renderer/pass-node.ts +++ b/packages/shader-lab-react/src/renderer/pass-node.ts @@ -13,7 +13,7 @@ import { vec3, vec4, } from "three/tsl" -import { buildBlendNode } from "./blend-modes" +import { buildBlendNode, type CompositionRole } from "./blend-modes" import type { BloomCompositor } from "./dual-filter-bloom" import type { LayerCompositeMode, LayerParameterValues, MaskConfig } from "../types/editor" @@ -56,6 +56,7 @@ export class PassNode { protected lastOutputTarget: THREE.WebGLRenderTarget | null = null private effectSwapGeneration = 0 private blendMode = "normal" + private compositionRole: CompositionRole = "effect" private compositeMode: LayerCompositeMode = "filter" private maskSource = "luminance" private maskMode = "multiply" @@ -66,7 +67,10 @@ export class PassNode { this.layerId = layerId this.scene = new THREE.Scene() this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + // The shader already composites the full pixel. NoBlending also prevents + // Three's opaque-material path from overwriting the computed alpha. this.material = new THREE.MeshBasicNodeMaterial() + this.material.blending = THREE.NoBlending this.opacityUniform = uniform(1) this.hueUniform = uniform(0) this.saturationUniform = uniform(1) @@ -114,6 +118,13 @@ export class PassNode { this.opacityUniform.value = opacity } + updateCompositionRole(role: CompositionRole): void { + if (role !== this.compositionRole) { + this.compositionRole = role + this.colorNodeDirty = true + } + } + updateBlendMode(blendMode: string): boolean { if (blendMode === this.blendMode) { return false @@ -227,6 +238,7 @@ export class PassNode { const generation = ++this.effectSwapGeneration const nextMaterial = new THREE.MeshBasicNodeMaterial() + nextMaterial.blending = THREE.NoBlending nextMaterial.colorNode = this.composeColorNode(nextEffectNode) const standbyScene = new THREE.Scene() @@ -286,6 +298,7 @@ export class PassNode { source: this.maskSource, } : undefined, + this.compositionRole, ) as Node } diff --git a/packages/shader-lab-react/src/renderer/photographic-cells-pass.ts b/packages/shader-lab-react/src/renderer/photographic-cells-pass.ts new file mode 100644 index 00000000..f1f0f6f4 --- /dev/null +++ b/packages/shader-lab-react/src/renderer/photographic-cells-pass.ts @@ -0,0 +1,421 @@ +import * as THREE from "three/webgpu" +import { + abs, + clamp, + dot, + float, + floor, + Fn, + fract, + If, + int, + length, + Loop, + max, + min, + mix, + select, + sin, + smoothstep, + step, + texture, + type TSLNode, + uniform, + uv, + vec2, + vec3, + vec4, +} from "three/tsl" +import { CELL_PAINT_SIZE, decodeCellPaintMask } from "./cell-paint-mask" +import { PassNode } from "./pass-node" +import type { LayerParameterValues } from "../types/editor" + +type Node = TSLNode +function number( + value: unknown, + fallback: number, + low: number, + high: number +): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(low, Math.min(high, value)) + : fallback +} + +/** Select photographic cells without replacing their interiors with flat samples. */ +export class PhotographicCellsPass extends PassNode { + private readonly painted = uniform(0) + private readonly paintGuide = uniform(0) + private readonly paintAspect = uniform(new THREE.Vector2(1, 1)) + private readonly paintTexture = new THREE.DataTexture( + new Uint8Array(CELL_PAINT_SIZE ** 2), + CELL_PAINT_SIZE, + CELL_PAINT_SIZE, + THREE.RedFormat + ) + private readonly paintSource = texture(this.paintTexture) + private paintValue = "" + private readonly regions = uniform(0) + private readonly regionSize = uniform(0.35) + private readonly outlineMode = uniform(2) + private readonly size = uniform(0.1) + private readonly cellAspect = uniform(1) + private readonly irregularity = uniform(0.35) + private readonly seed = uniform(1) + private readonly selection = uniform(0) + private readonly threshold = uniform(0.35) + private readonly invert = uniform(0) + private readonly gap = uniform(0.08) + private readonly softness = uniform(0) + private readonly outline = uniform(0) + private readonly outlineColor = uniform(new THREE.Color("#e8e5dc")) + private readonly cutout = uniform(1) + private readonly aspect = uniform(new THREE.Vector2(1, 1)) + private readonly resolution = uniform(new THREE.Vector2(1, 1)) + private readonly source: Node + private readonly placeholder: THREE.Texture + + constructor(id: string) { + super(id) + this.paintTexture.minFilter = THREE.NearestFilter + this.paintTexture.magFilter = THREE.NearestFilter + this.paintTexture.generateMipmaps = false + this.paintTexture.needsUpdate = true + this.placeholder = new THREE.Texture() + this.placeholder.type = THREE.HalfFloatType + this.placeholder.minFilter = THREE.NearestFilter + this.placeholder.magFilter = THREE.NearestFilter + this.placeholder.generateMipmaps = false + this.source = texture(this.placeholder) + this.rebuildEffectNode() + } + + override render( + renderer: THREE.WebGPURenderer, + input: THREE.Texture, + output: THREE.WebGLRenderTarget, + time: number, + delta: number + ): void { + this.source.value = input + super.render(renderer, input, output, time, delta) + } + + override resize(width: number, height: number): void { + ;(this.resolution.value as THREE.Vector2).set( + Math.max(1, width), + Math.max(1, height) + ) + } + + override updateLogicalSize(width: number, height: number): void { + const shorter = Math.max(1, Math.min(width, height)) + ;(this.aspect.value as THREE.Vector2).set( + Math.max(1, width) / shorter, + Math.max(1, height) / shorter + ) + } + + override updateParams(params: LayerParameterValues): void { + this.painted.value = params.mode === "paint" ? 1 : 0 + this.paintGuide.value = params._paintGuide === true ? 0.18 : 0 + const paintValue = + typeof params.paintMask === "string" ? params.paintMask : "" + // Video/source changes do not decode or upload the painted mask again. + if (paintValue !== this.paintValue) { + const mask = decodeCellPaintMask(paintValue) + this.paintTexture.image.data?.set(mask.data) + this.paintTexture.needsUpdate = true + ;(this.paintAspect.value as THREE.Vector2).set(mask.width, mask.height) + this.paintValue = paintValue + } + this.regions.value = params.mode === "regions" ? 1 : 0 + this.regionSize.value = number(params.regionSize, 0.35, 0.05, 2) + this.outlineMode.value = 2 + if (params.outlineMode === "none") this.outlineMode.value = 0 + if (params.outlineMode === "perimeter") this.outlineMode.value = 1 + this.size.value = number(params.size, 0.1, 0.015, 0.5) + this.cellAspect.value = number(params.cellAspect, 1, 0.25, 4) + this.irregularity.value = number(params.irregularity, 0.35, 0, 1) + this.seed.value = number(params.seed, 1, 0, 1000) + this.selection.value = 0 + if (params.selection === "dark") this.selection.value = 1 + if (params.selection === "random") this.selection.value = 2 + this.threshold.value = number(params.threshold, 0.35, 0, 1) + this.invert.value = params.invert === true ? 1 : 0 + this.gap.value = number(params.gap, 0.08, 0, 1) + this.softness.value = number(params.softness, 0, 0, 0.025) + this.outline.value = number(params.outline, 0, 0, 0.5) + ;(this.outlineColor.value as THREE.Color).set( + typeof params.outlineColor === "string" ? params.outlineColor : "#e8e5dc" + ) + this.cutout.value = params.output === "keep-image" ? 0 : 1 + } + + protected override buildEffectNode(): Node { + if (!this.source) return this.inputNode + return Fn(() => { + const hash = (value: Node) => + fract( + sin(dot(value, vec2(127.1, 311.7)).add(this.seed.mul(74.7))).mul( + 43758.5453 + ) + ) + // Automatic and painted selection share the same geometry and outline. + const sampleTone = (position: Node) => { + const inset = vec2(0.5).div(this.resolution) + const probe = this.source + .sample( + clamp(position.div(this.aspect).add(0.5), inset, vec2(1).sub(inset)) + ) + .level(0) + return clamp(dot(probe.rgb, vec3(0.2126, 0.7152, 0.0722)), 0, 1) + } + const rowGeometry = (row: Node) => { + const width = this.size + .mul(this.cellAspect) + .mul(mix(1, hash(vec2(row, 17)).mul(0.8).add(0.6), this.irregularity)) + const shift = hash(vec2(row, 53)) + .sub(0.5) + .mul(width) + .mul(this.irregularity) + return { width, shift } + } + const cellAt = Fn(([id]: [Node]) => { + const { width, shift } = rowGeometry(id.y) + const center = vec2( + id.x.add(0.5).mul(width).sub(shift), + id.y.add(0.5).mul(this.size) + ) + const score = float(0).toVar() + If(this.painted.greaterThan(0.5), () => { + const paintUV = center.div(this.paintAspect).add(0.5) + const inside = paintUV.x + .greaterThanEqual(0) + .and(paintUV.x.lessThan(1)) + .and(paintUV.y.greaterThanEqual(0)) + .and(paintUV.y.lessThan(1)) + score.assign( + select( + inside, + this.paintSource.sample(paintUV).level(0).r, + float(0) + ) + ) + }).Else(() => { + If(this.regions.greaterThan(0.5), () => { + // Smooth a field in composition space, then quantize only its boundary + // into cells. Region Size never changes the photographic samples inside. + const field = center.div(this.regionSize) + const base = floor(field) + const fraction = fract(field) + const blend = fraction + .mul(fraction) + .mul(float(3).sub(fraction.mul(2))) + const values = vec4(0).toVar() + If(this.selection.greaterThan(1.5), () => { + values.assign( + vec4( + hash(base), + hash(base.add(vec2(1, 0))), + hash(base.add(vec2(0, 1))), + hash(base.add(1)) + ) + ) + }).Else(() => { + values.assign( + vec4( + sampleTone(base.mul(this.regionSize)), + sampleTone(base.add(vec2(1, 0)).mul(this.regionSize)), + sampleTone(base.add(vec2(0, 1)).mul(this.regionSize)), + sampleTone(base.add(1).mul(this.regionSize)) + ) + ) + If(this.selection.greaterThan(0.5), () => { + values.assign(float(1).sub(values)) + }) + }) + score.assign( + mix( + mix(values.x, values.y, blend.x), + mix(values.z, values.w, blend.x), + blend.y + ) + ) + }).Else(() => { + If(this.selection.greaterThan(1.5), () => { + score.assign(hash(id)) + }).Else(() => { + const luma = sampleTone(center) + score.assign( + select( + this.selection.greaterThan(0.5), + float(1).sub(luma), + luma + ) + ) + }) + }) + }) + const automatic = select( + this.threshold.lessThanEqual(0), + float(1), + select( + this.threshold.greaterThanEqual(1), + float(0), + step(this.threshold, score) + ) + ) + const chosen = select( + this.painted.greaterThan(0.5), + step(0.5, score), + automatic + ) + const selected = select( + this.invert.greaterThan(0.5), + float(1).sub(chosen), + chosen + ) + return vec4(center, width, selected) + }).setLayout({ + name: "photographicCell", + type: "vec4", + inputs: [{ name: "id", type: "vec2" }], + }) + const screen = vec2(uv().x, float(1).sub(uv().y)) + const point = screen.sub(0.5).mul(this.aspect) + const row = floor(point.y.div(this.size)) + const geometry = rowGeometry(row) + const column = floor(point.x.add(geometry.shift).div(geometry.width)) + const current = cellAt(vec2(column, row)).toVar() + const center = current.xy + const width = current.z + const selected = current.w + const cell = vec2(width, this.size) + const local = abs(point.sub(center)) + const half = cell.mul(float(1).sub(this.gap)).mul(0.5) + const distance = max(local.x.sub(half.x), local.y.sub(half.y)) + const pixel = max( + this.aspect.x.div(this.resolution.x), + this.aspect.y.div(this.resolution.y) + ) + const edge = max(pixel.mul(0.75), this.softness) + const coverage = select( + this.gap.equal(0), + float(1), + float(1).sub(smoothstep(edge.negate(), edge, distance)) + ).mul(select(this.gap.greaterThanEqual(1), float(0), float(1))) + const mask = coverage.mul(selected) + const strokeWidth = min(width, this.size).mul(this.outline) + const outlineDistance = distance.toVar() + If( + this.outlineMode + .equal(1) + .and(this.gap.equal(0)) + .and(this.outline.greaterThan(0)), + () => { + // Distance to the complement of the selected union, rather than to each + // cell edge. Search neighboring rows by their own widths/staggers so + // T-junctions and narrow cells do not leave internal seams. + const boundary = min( + this.aspect.x.mul(0.5).sub(abs(point.x)), + this.aspect.y.mul(0.5).sub(abs(point.y)) + ).toVar() + // Only pixels close enough to a cell edge can carry a perimeter. + // Bound neighbor queries by the stroke reach before sampling the source. + const reach = strokeWidth.add(edge) + If( + selected + .greaterThan(0.5) + .and(distance.greaterThanEqual(reach.negate())), + () => { + const rows = min(floor(reach.div(this.size)).add(1), 2) + Loop( + { start: 0, end: int(rows.mul(2).add(1)), type: "int" }, + ({ i }) => { + const neighborRow = row.add(float(i).sub(rows)) + const adjacent = rowGeometry(neighborRow) + const nearestColumn = floor( + point.x.add(adjacent.shift).div(adjacent.width) + ) + const columns = min( + floor(reach.div(adjacent.width)).add(1), + 2 + ) + Loop( + { + start: 0, + end: int(columns.mul(2).add(1)), + type: "int", + name: "j", + }, + ({ j }) => { + const neighborColumn = nearestColumn.add( + float(j).sub(columns) + ) + const neighborCenter = vec2( + neighborColumn + .add(0.5) + .mul(adjacent.width) + .sub(adjacent.shift), + neighborRow.add(0.5).mul(this.size) + ) + const outside = length( + max( + abs(point.sub(neighborCenter)).sub( + vec2(adjacent.width, this.size).mul(0.5) + ), + vec2(0) + ) + ) + If( + outside.lessThan(boundary).and(outside.lessThan(reach)), + () => { + const neighbor = cellAt( + vec2(neighborColumn, neighborRow) + ) + If(neighbor.w.lessThan(0.5), () => { + boundary.assign(outside) + }) + } + ) + } + ) + } + ) + } + ) + outlineDistance.assign(boundary.negate()) + } + ) + const interior = float(1).sub( + smoothstep(edge.negate(), edge, outlineDistance.add(strokeWidth)) + ) + // Subtract the inset fill from the outer coverage. Multiplying two edge + // fades adds extra ink when a gap opens, especially for thin outlines. + const strokeCoverage = max(coverage.sub(interior), 0).mul( + select( + this.outline.greaterThan(0).and(this.outlineMode.greaterThan(0)), + float(1), + float(0) + ) + ) + // RGB is straight-alpha: normalize here because mask applies coverage below. + const stroke = strokeCoverage.div(max(coverage, 0.000001)) + const original = this.inputNode + const rgb = mix(original.rgb, this.outlineColor, stroke) + // Outlines never manufacture coverage in transparent parts of the source. + return select( + this.cutout.greaterThan(0.5), + vec4(rgb, original.a.mul(max(mask, this.paintGuide.mul(this.painted)))), + vec4(mix(original.rgb, rgb, mask), original.a) + ) + })() + } + + override dispose(): void { + this.paintTexture.dispose() + this.placeholder.dispose() + super.dispose() + } +} diff --git a/packages/shader-lab-react/src/renderer/pipeline-manager.ts b/packages/shader-lab-react/src/renderer/pipeline-manager.ts index ca6886b3..19f8092e 100644 --- a/packages/shader-lab-react/src/renderer/pipeline-manager.ts +++ b/packages/shader-lab-react/src/renderer/pipeline-manager.ts @@ -1,3 +1,10 @@ +import type { RenderableLayerConfig } from "./contracts" +import { + type CompositionNode, + flattenComposition, + isCompositionGroup, +} from "./composition-tree" +import { GroupPass } from "./group-pass" import { float, type TSLNode, texture as tslTexture, uv, vec2 } from "three/tsl" import * as THREE from "three/webgpu" import type { ShaderLabCompositeMode, ShaderLabLayerConfig } from "../types" @@ -29,6 +36,8 @@ import { PixelTrailPass } from "./pixel-trail-pass" import { PixelationPass } from "./pixelation-pass" import { PlotterPass } from "./plotter-pass" import { PosterizePass } from "./posterize-pass" +import { PhotographicCellsPass } from "./photographic-cells-pass" +import { DisplacedRingsPass } from "./displaced-rings-pass" import { SlicePass } from "./slice-pass" import { SmearPass } from "./smear-pass" import { TextPass } from "./text-pass" @@ -204,6 +213,7 @@ export class PipelineManager { const blitUv = vec2(uv().x, float(1).sub(uv().y)) this.blitInputNode = tslTexture(createPipelinePlaceholder(), blitUv) this.blitMaterial = new THREE.MeshBasicNodeMaterial() + this.blitMaterial.blending = THREE.NoBlending this.blitMaterial.colorNode = this.blitInputNode const blitMesh = new THREE.Mesh( new THREE.PlaneGeometry(2, 2), @@ -213,8 +223,11 @@ export class PipelineManager { this.blitScene.add(blitMesh) } - syncLayers(layers: ShaderLabLayerConfig[]): void { - const incomingIds = new Set(layers.map((layer) => layer.id)) + syncLayers(layers: CompositionNode[]): void { + const getId = (node: CompositionNode) => + isCompositionGroup(node) ? node.id : node.id + const flattened = flattenComposition(layers, (node) => node.id) + const incomingIds = new Set(flattened.map(getId)) for (const [layerId, pass] of this.passMap) { if (incomingIds.has(layerId)) { @@ -229,33 +242,70 @@ export class PipelineManager { this.dirty = true } - const orderedPasses: LayerPassNode[] = [] - - for (const layer of layers) { - const signature = createLayerSignature(layer) - let pass = this.passMap.get(layer.id) + for (const node of flattened) { + const layerId = getId(node) + const group = isCompositionGroup(node) + const signature = group + ? JSON.stringify([node.visible, node.opacity, node.blendMode]) + : createLayerSignature(node) + let pass = this.passMap.get(layerId) + + if (pass && pass instanceof GroupPass !== group) { + pass.dispose() + this.passMap.delete(layerId) + this.layerSignatures.delete(layerId) + this.compilingPasses.delete(layerId) + this.compiledVersions.delete(layerId) + pass = undefined + } + const created = !pass if (!pass) { - pass = this.createPass(layer) + pass = group + ? new GroupPass( + layerId, + (child) => this.isActive(child), + (...args) => this.renderPass(...args) + ) + : this.createPass(node) pass.resize(this.width, this.height) pass.updateLogicalSize(this.logicalWidth, this.logicalHeight) - this.passMap.set(layer.id, pass) + this.passMap.set(layerId, pass) this.dirty = true } - if (this.layerSignatures.get(layer.id) !== signature) { + if (this.layerSignatures.get(layerId) !== signature) { const versionBefore = pass.getMaterialVersion() - this.layerSignatures.set(layer.id, signature) - this.applyLayerState(pass, layer) + this.layerSignatures.set(layerId, signature) + if (group) { + pass.enabled = node.visible + pass.updateOpacity(clampUnit(node.opacity)) + pass.updateBlendMode(node.blendMode) + pass.flushColorNode() + } else { + this.applyLayerState(pass, node) + } this.dirty = true - if (pass.getMaterialVersion() !== versionBefore) { + if ((created && group) || pass.getMaterialVersion() !== versionBefore) { this.scheduleCompile(pass) } } + } - orderedPasses.push(pass) + // All passes exist before wiring children, so reparenting preserves media state. + for (const node of flattened) { + if (!isCompositionGroup(node)) continue + const pass = this.passMap.get(node.id) as GroupPass + if ( + pass.setChildren( + node.children.map((child) => this.passMap.get(getId(child))!) + ) + ) { + this.dirty = true + } } + const orderedPasses = layers.map((node) => this.passMap.get(getId(node))!) if ( orderedPasses.length !== this.passes.length || @@ -267,12 +317,7 @@ export class PipelineManager { } render(time: number, delta: number): boolean { - const activePasses = this.passes.filter( - (pass) => - pass.enabled && - (!this.compilingPasses.has(pass.layerId) || - this.compiledVersions.has(pass.layerId)) - ) + const activePasses = this.passes.filter((pass) => this.isActive(pass)) const needsContinuousRender = activePasses.some((pass) => pass.needsContinuousRender() ) @@ -295,7 +340,7 @@ export class PipelineManager { let writeTarget = this.rtB for (const pass of activePasses) { - pass.render(this.renderer, readTarget.texture, writeTarget, time, delta) + this.renderPass(pass, readTarget.texture, writeTarget, time, delta) const previousRead = readTarget readTarget = writeTarget writeTarget = previousRead @@ -313,12 +358,7 @@ export class PipelineManager { delta: number, inputTexture?: THREE.Texture ): THREE.Texture | null { - const activePasses = this.passes.filter( - (pass) => - pass.enabled && - (!this.compilingPasses.has(pass.layerId) || - this.compiledVersions.has(pass.layerId)) - ) + const activePasses = this.passes.filter((pass) => this.isActive(pass)) const needsContinuousRender = activePasses.some((pass) => pass.needsContinuousRender() ) @@ -347,7 +387,7 @@ export class PipelineManager { let writeTarget = this.rtB for (const pass of activePasses) { - pass.render(this.renderer, readTarget.texture, writeTarget, time, delta) + this.renderPass(pass, readTarget.texture, writeTarget, time, delta) const previousRead = readTarget readTarget = writeTarget writeTarget = previousRead @@ -413,6 +453,26 @@ export class PipelineManager { this.compiledVersions.clear() } + private isActive(pass: PassNode): boolean { + return ( + pass.enabled && + (!this.compilingPasses.has(pass.layerId) || + this.compiledVersions.has(pass.layerId)) + ) + } + + private renderPass( + pass: PassNode, + input: THREE.Texture, + output: THREE.WebGLRenderTarget, + time: number, + delta: number, + _timelineTime = time + ): boolean { + pass.render(this.renderer, input, output, time, delta) + return true + } + private scheduleCompile(pass: LayerPassNode): void { const version = pass.getMaterialVersion() if (this.compiledVersions.get(pass.layerId) === version) { @@ -427,11 +487,13 @@ export class PipelineManager { renderer .compileAsync(scene, camera) .then(() => { + if (this.passMap.get(pass.layerId) !== pass) return this.compilingPasses.delete(pass.layerId) this.compiledVersions.set(pass.layerId, pass.getMaterialVersion()) this.dirty = true }) .catch(() => { + if (this.passMap.get(pass.layerId) !== pass) return this.compilingPasses.delete(pass.layerId) }) } @@ -441,6 +503,19 @@ export class PipelineManager { layer: ShaderLabLayerConfig ): void { pass.enabled = layer.visible + if ( + layer.type === "displaced-rings" || + layer.type === "photographic-cells" + ) { + pass.updateCompositionRole("transform") + } else { + pass.updateCompositionRole( + layer.kind === "effect" || + (layer.type === "custom-shader" && layer.params.effectMode === true) + ? "effect" + : "source" + ) + } pass.updateOpacity(clampUnit(layer.opacity)) pass.updateBlendMode(layer.blendMode) const compositeMode: ShaderLabCompositeMode = @@ -542,6 +617,10 @@ export class PipelineManager { return new ThresholdPass(layer.id) case "pixel-sorting": return new PixelSortingPass(layer.id) + case "photographic-cells": + return new PhotographicCellsPass(layer.id) + case "displaced-rings": + return new DisplacedRingsPass(layer.id) case "slice": return new SlicePass(layer.id) case "smear": diff --git a/packages/shader-lab-react/src/types.ts b/packages/shader-lab-react/src/types.ts index 99c0a9b8..f4b0bb4f 100644 --- a/packages/shader-lab-react/src/types.ts +++ b/packages/shader-lab-react/src/types.ts @@ -5,7 +5,7 @@ export type ShaderLabParameterValue = | [number, number] | [number, number, number] -export type ShaderLabLayerKind = "effect" | "source" +export type ShaderLabLayerKind = "effect" | "source" | "group" export type ShaderLabSourceLayerType = | "custom-shader" @@ -19,6 +19,8 @@ export type ShaderLabSourceLayerType = | "video" export type ShaderLabEffectLayerType = + | "photographic-cells" + | "displaced-rings" | "ascii" | "blob-tracking" | "bloom" @@ -44,6 +46,7 @@ export type ShaderLabEffectLayerType = | "voxel" export type ShaderLabLayerType = + | "group" | ShaderLabEffectLayerType | ShaderLabSourceLayerType @@ -156,6 +159,8 @@ export interface ShaderLabTimelineConfig { } export interface ShaderLabLayerConfig { + /** Parent group ID. Layers are stored in top-first, depth-first order. */ + parentId?: string | null asset?: ShaderLabAssetSource blendMode: ShaderLabBlendMode compositeMode: ShaderLabCompositeMode diff --git a/public/examples/displaced-rings.webp b/public/examples/displaced-rings.webp new file mode 100644 index 00000000..dd9b0e19 Binary files /dev/null and b/public/examples/displaced-rings.webp differ diff --git a/public/examples/photographic-cells.webp b/public/examples/photographic-cells.webp new file mode 100644 index 00000000..e23d2d11 Binary files /dev/null and b/public/examples/photographic-cells.webp differ diff --git a/src/components/editor/cell-paint-controls.tsx b/src/components/editor/cell-paint-controls.tsx new file mode 100644 index 00000000..6108cb3f --- /dev/null +++ b/src/components/editor/cell-paint-controls.tsx @@ -0,0 +1,84 @@ +"use client" +import { Button } from "@/components/ui/button" +import { Select } from "@/components/ui/select" +import { Slider } from "@/components/ui/slider" +import { Typography } from "@/components/ui/typography" +import { canPaintCellLayer, useCellPaintStore } from "@/store/cell-paint-store" +import { useLayerStore } from "@/store/layer-store" + +export function CellPaintControls({ layerId }: { layerId: string }) { + const editing = useCellPaintStore((s) => s.layerId === layerId) + const tool = useCellPaintStore((s) => s.tool) + const size = useCellPaintStore((s) => s.brushSize) + const allowed = useLayerStore((s) => + canPaintCellLayer(s.layers, layerId, s.selectedLayerId) + ) + const hasPaint = useLayerStore( + (s) => !!s.layers.find((l) => l.id === layerId)?.params.paintMask + ) + return ( +
+ + Painted reveal + + + Paint over your subject. Cell Size shapes the stepped edge. The faint + guide disappears when you finish. + +
+ + +
+ + moveLayer( + layerId, + value === "scene" ? null : (value?.slice(6) ?? null) + ) + } + options={[ + { label: "Scene", value: "scene" }, + ...groups + .filter( + (group) => moveLayerToGroup(layers, layerId, group.id) !== null + ) + .map((group) => ({ + label: paths.get(group.id)!, + value: `group:${group.id}`, + })), + ]} + value={layer.parentId ? `group:${layer.parentId}` : "scene"} + /> + + ) +} diff --git a/src/components/editor/layer-picker.tsx b/src/components/editor/layer-picker.tsx index 56539187..7975eb8f 100644 --- a/src/components/editor/layer-picker.tsx +++ b/src/components/editor/layer-picker.tsx @@ -59,6 +59,8 @@ export type AddLayerAction = | "pixel-sorting" | "plotter" | "posterize" + | "photographic-cells" + | "displaced-rings" | "slice" | "smear" | "threshold" @@ -113,6 +115,8 @@ const SOURCE_ITEMS: readonly SourceItem[] = SOURCE_ICONS.map( ) const EFFECT_ORDER: readonly AddLayerAction[] = [ + "photographic-cells", + "displaced-rings", "ascii", "ink", "pattern", diff --git a/src/components/editor/layer-sidebar.tsx b/src/components/editor/layer-sidebar.tsx index d444a1d1..84d9fb32 100644 --- a/src/components/editor/layer-sidebar.tsx +++ b/src/components/editor/layer-sidebar.tsx @@ -1,10 +1,13 @@ "use client" import { + ChevronDownIcon, + ChevronRightIcon, DotsVerticalIcon, DragHandleDots2Icon, EyeClosedIcon, EyeOpenIcon, FileIcon, + GroupIcon, ImageIcon, LayoutIcon, ShadowIcon, @@ -12,14 +15,12 @@ import { TransparencyGridIcon, TrashIcon, } from "@radix-ui/react-icons" -import { Reorder, useDragControls } from "motion/react" import Image from "next/image" import { type ChangeEvent, memo, type MouseEvent as ReactMouseEvent, type ReactNode, - type PointerEvent as ReactPointerEvent, useEffect, useMemo, useRef, @@ -30,6 +31,7 @@ import { type AddLayerAction, LayerPicker, } from "@/components/editor/layer-picker" +import { useLayerDrag } from "@/components/editor/use-layer-drag" import { GlassPanel } from "@/components/ui/glass-panel" import { IconButton } from "@/components/ui/icon-button" import { Select } from "@/components/ui/select" @@ -38,6 +40,10 @@ import { Typography } from "@/components/ui/typography" import { playUISound } from "@/lib/audio/shader-lab-sounds" import { cn } from "@/lib/cn" import { duplicateLayers } from "@/lib/editor/duplicate-layers" +import { + groupingSelection, + type LayerDropTarget, +} from "@/lib/editor/layer-groups" import { getAssetAccept, inferFileAssetKind } from "@/lib/editor/media-file" import { getSeedableMediaDuration } from "@/lib/editor/timeline-duration" import { useAssetStore } from "@/store/asset-store" @@ -46,7 +52,7 @@ import { useLayerStore } from "@/store/layer-store" import { useTimelineStore } from "@/store/timeline-store" import type { AssetKind, EditorAsset, EditorLayer } from "@/types/editor" -type LayerAction = "delete" | "duplicate" | "reset" +type LayerAction = "delete" | "duplicate" | "reset" | "ungroup" | "up" | "down" const thumbnailBaseClassName = "relative size-7 overflow-hidden rounded-[var(--ds-radius-thumb)] border border-[var(--ds-border-divider)]" @@ -117,6 +123,10 @@ function inferSelectedFileKind(file: File): AssetKind | null { } type LayerListItemProps = { + children?: ReactNode + dragPlacement?: LayerDropTarget["placement"] | undefined + isDragging: boolean + onDragStart: ReturnType["start"] asset: EditorAsset | null hasMissingAsset: boolean isFloatingPanelDragging: boolean @@ -143,35 +153,30 @@ const LAYER_ACTION_OPTIONS = [ function LayerListShell({ children, - isFloatingPanelDragging, - onReorder, - values, + nested = false, }: { children: ReactNode - isFloatingPanelDragging: boolean - onReorder: (nextLayers: EditorLayer[]) => void - values: EditorLayer[] + nested?: boolean }) { - const className = - "flex max-h-[min(52vh,480px)] flex-col gap-0.5 overflow-y-auto p-1" - - if (isFloatingPanelDragging) { - return
    {children}
- } - return ( - {children} - + ) } +function focusNameInput(node: HTMLInputElement | null) { + node?.focus() + node?.select() +} + const LayerListItem = memo(function LayerListItem({ asset, hasMissingAsset, @@ -183,68 +188,127 @@ const LayerListItem = memo(function LayerListItem({ onRelinkPick, onSelectLayer, onSetLayerVisibility, + children, + dragPlacement, + isDragging, + onDragStart, }: LayerListItemProps) { - const dragControls = useDragControls() - - function handlePointerDown(event: ReactPointerEvent) { - if (layer.locked || isFloatingPanelDragging) { - return - } + const [renaming, setRenaming] = useState(false) + const [name, setName] = useState(layer.name) + const cancelRename = useRef(false) + const renameLayer = useLayerStore((state) => state.renameLayer) + const setExpanded = useLayerStore((state) => state.setLayerExpanded) + const isGroup = layer.kind === "group" + + function startRename() { + cancelRename.current = false + setName(layer.name) + setRenaming(true) + } - dragControls.start(event) + function finishRename() { + if (!cancelRename.current) renameLayer(layer.id, name) + setRenaming(false) } - if (isFloatingPanelDragging) { - return ( -
  • +
    -
    - +
    + - - + )} + {renaming ? ( + setName(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + finishRename() + } + if (event.key === "Escape") { + cancelRename.current = true + setRenaming(false) + } + }} + ref={focusNameInput} + value={name} + /> + ) : ( +
    - + + )}
    - onLayerAction(layer.id, value as LayerAction)} - options={LAYER_ACTION_OPTIONS} - placeholder={} - popupClassName="min-w-[152px]" - triggerAriaLabel={`Layer actions for ${layer.name}`} - triggerVariant="icon" - uiSound="none" - valueClassName="inline-flex items-center justify-center leading-none text-[var(--ds-color-text-tertiary)] [&_svg]:h-[14px] [&_svg]:w-[14px]" - /> - - {hasMissingAsset ? ( - { - event.stopPropagation() - onRelinkPick(layer) - }} - uiSound="none" - variant="ghost" - > - - - ) : ( - { - event.stopPropagation() - onSetLayerVisibility(layer.id, !layer.visible) - }} - tooltip="Toggle visibility" - uiSound={layer.visible ? "action.visibilityOff" : "action.visibilityOn"} - variant="ghost" - > - {layer.visible ? ( - - ) : ( - - )} - + {dragPlacement === "before" && ( +
    )} - - { - event.stopPropagation() - onLayerAction(layer.id, "delete") - }} - tooltip="Delete layer" - uiSound="none" - variant="ghost" - > - - - + {content} + {dragPlacement === "after" && ( +
    + )} +
  • ) }) export function LayerSidebar() { + const layerDrag = useLayerDrag() const imageInputRef = useRef(null) const relinkInputRef = useRef(null) const relinkTargetRef = useRef<{ @@ -425,12 +393,25 @@ export function LayerSidebar() { const [freezeDesktopLayerList, setFreezeDesktopLayerList] = useState(true) const layers = useLayerStore((state) => state.layers) - const hoveredLayerId = useLayerStore((state) => state.hoveredLayerId) const selectedLayerIds = useLayerStore((state) => state.selectedLayerIds) - const selectedLayerId = useLayerStore((state) => state.selectedLayerId) const addLayer = useLayerStore((state) => state.addLayer) + const groupLayers = useLayerStore((state) => state.groupLayers) + const ungroupLayer = useLayerStore((state) => state.ungroupLayer) + const reorderSiblings = useLayerStore((state) => state.reorderSiblings) + const [groupError, setGroupError] = useState(null) + const canGroup = + !selectedLayerIds.length || + groupingSelection(layers, selectedLayerIds).length > 0 + function handleGroup() { + const id = groupLayers(selectedLayerIds) + setGroupError( + id + ? null + : "Groups support at most eight levels. Select layers within the same group." + ) + if (id) playUISound("action.addLayer") + } const removeLayers = useLayerStore((state) => state.removeLayers) - const replaceState = useLayerStore((state) => state.replaceState) const resetLayerParams = useLayerStore((state) => state.resetLayerParams) const selectLayerWithModifiers = useLayerStore( (state) => state.selectLayerWithModifiers @@ -536,7 +517,24 @@ export function LayerSidebar() { ? selectedLayerIds : [layerId] - if (action === "delete") { + if (action === "ungroup") { + ungroupLayer(layerId) + } else if (action === "up" || action === "down") { + const layer = layers.find((entry) => entry.id === layerId) + if (!layer || layer.locked) return + const siblings = layers + .filter( + (entry) => (entry.parentId ?? null) === (layer.parentId ?? null) + ) + .map((entry) => entry.id) + const from = siblings.indexOf(layerId) + const to = from + (action === "up" ? -1 : 1) + if (to >= 0 && to < siblings.length) { + siblings.splice(from, 1) + siblings.splice(to, 0, layerId) + reorderSiblings(layer.parentId ?? null, siblings) + } + } else if (action === "delete") { removeLayers(targetLayerIds) playUISound("action.deleteLayer") } else if (action === "duplicate") { @@ -638,10 +636,6 @@ export function LayerSidebar() { } } - function handleReorder(nextLayers: EditorLayer[]) { - replaceState(nextLayers, selectedLayerId, hoveredLayerId, selectedLayerIds) - } - function handleSelectLayer( layerId: string, event: ReactMouseEvent @@ -660,6 +654,82 @@ export function LayerSidebar() { setLayersVisibility(targetLayerIds, visible) } + function renderLayerTree( + parentId: string | null, + frozen: boolean + ): ReactNode { + const siblings = layers.filter( + (layer) => (layer.parentId ?? null) === parentId + ) + return ( + + {siblings.map((layer) => { + const asset = layer.assetId + ? (assetsById.get(layer.assetId) ?? null) + : null + return ( + + {layer.kind === "group" && layer.expanded + ? renderLayerTree(layer.id, frozen) + : null} + + ) + })} + + ) + } + + const dragStatus = ( + + {layerDrag.preview?.message} + + ) + + const groupButton = ( + + + + ) + return ( <> + {groupButton} - - {layers.map((layer) => { - const asset = layer.assetId - ? (assetsById.get(layer.assetId) ?? null) - : null - const hasMissingAsset = Boolean(layer.assetId && !asset) - const isSelected = selectedLayerIds.includes(layer.id) - - return ( - - ) - })} - + {renderLayerTree(null, false)} + {dragStatus} + {groupError && ( + + {groupError} + + )} @@ -772,7 +819,7 @@ export function LayerSidebar() { {({ dragHandleProps, suppressResize: _suppressResize }) => (
    @@ -807,6 +854,7 @@ export function LayerSidebar() { > + {groupButton}
    - - {layers.map((layer) => { - const asset = layer.assetId - ? (assetsById.get(layer.assetId) ?? null) - : null - const hasMissingAsset = Boolean(layer.assetId && !asset) - const isSelected = selectedLayerIds.includes(layer.id) - - return ( - - ) - })} - + {renderLayerTree(null, shouldFreezeDesktopLayerList)} + {dragStatus} + {groupError && ( + + {groupError} + + )}
    )} diff --git a/src/components/editor/properties-sidebar-content.tsx b/src/components/editor/properties-sidebar-content.tsx index f7f6d560..1de446c9 100644 --- a/src/components/editor/properties-sidebar-content.tsx +++ b/src/components/editor/properties-sidebar-content.tsx @@ -1,5 +1,8 @@ "use client" +import { CellPaintControls } from "./cell-paint-controls" +import { LayerGroupLocation } from "@/components/editor/layer-group-location" + import { TextAlignRightIcon } from "@radix-ui/react-icons" import { AnimatePresence, motion } from "motion/react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -549,6 +552,7 @@ export function SelectedLayerPropertiesContent({ valueSuffix="%" /> +
    Blend @@ -566,24 +570,7 @@ export function SelectedLayerPropertiesContent({ />
    -
    - - Mode - - { if (value) { - setLayerMaskConfig(layerId, { - source: value as MaskSource, - }) + setLayerCompositeMode( + layerId, + value as LayerCompositeMode + ) } }} - options={maskSourceOptions} + options={compositeModeOptions} triggerClassName="w-[132px]" - value={maskConfig.source} + value={compositeMode} />
    -
    - - Mask Mode - - { + if (value) { + setLayerMaskConfig(layerId, { + source: value as MaskSource, + }) + } + }} + options={maskSourceOptions} + triggerClassName="w-[132px]" + value={maskConfig.source} + /> +
    -
    - - Invert - - - setLayerMaskConfig(layerId, { invert: nextValue }) - } - /> -
    +
    + + Mask Mode + +