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/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/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/.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/V3-ROADMAP.md b/V3-ROADMAP.md index d7a7ac6b..142c1a25 100644 --- a/V3-ROADMAP.md +++ b/V3-ROADMAP.md @@ -6,13 +6,19 @@ 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 catorce referencias elegidas. + +Avance técnico de 1.3: el contrato interno de render admite grupos aislados y hasta ocho niveles de anidamiento, con pruebas de alcance, opacidad, orden, recursos y exportación. La creación y organización de grupos en el editor, deshacer, persistencia, exportación de configuración y máscaras siguen pendientes; esta base no cierra 1.3. ## Fase 1 — Resolver la base de composición y proteger proyectos existentes 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..1f5631d0 100644 --- a/packages/shader-lab-react/README.md +++ b/packages/shader-lab-react/README.md @@ -347,6 +347,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 +660,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-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..df5a2d88 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" + 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,33 @@ 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))) + 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/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..98b94359 100644 --- a/packages/shader-lab-react/src/renderer/contracts.ts +++ b/packages/shader-lab-react/src/renderer/contracts.ts @@ -1,6 +1,7 @@ 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 @@ -16,7 +17,7 @@ export interface ProjectClock { export interface RendererFrame { clock: ProjectClock - layers: ShaderLabLayerConfig[] + layers: CompositionNode[] logicalSize: RendererSize outputSize: RendererSize pixelRatio: number 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/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/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/pipeline-manager.ts b/packages/shader-lab-react/src/renderer/pipeline-manager.ts index ca6886b3..3c17916a 100644 --- a/packages/shader-lab-react/src/renderer/pipeline-manager.ts +++ b/packages/shader-lab-react/src/renderer/pipeline-manager.ts @@ -1,3 +1,9 @@ +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" @@ -204,6 +210,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 +220,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 +239,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 +314,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 +337,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 +355,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 +384,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 +450,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 +484,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 +500,12 @@ export class PipelineManager { layer: ShaderLabLayerConfig ): void { pass.enabled = layer.visible + 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 = diff --git a/src/lib/editor/config/layer-registry.ts b/src/lib/editor/config/layer-registry.ts index 908a9490..a9227a2f 100644 --- a/src/lib/editor/config/layer-registry.ts +++ b/src/lib/editor/config/layer-registry.ts @@ -26,6 +26,16 @@ const mediaPlacementParams = [ ], type: "select", }, + { + defaultValue: true, + key: "transparentBounds", + label: "Transparent Borders", + type: "boolean", + visibleWhen: { + equals: "contain", + key: "fitMode", + }, + }, { defaultValue: 1, key: "scale", diff --git a/src/lib/editor/project-file.ts b/src/lib/editor/project-file.ts index 82937213..60a0350d 100644 --- a/src/lib/editor/project-file.ts +++ b/src/lib/editor/project-file.ts @@ -603,6 +603,15 @@ export function migrateLayerParams( ): LayerParameterValues { const params: LayerParameterValues = { ...layer.params } + // Files without this setting predate transparent contain bounds. Preserve + // their black borders before filling missing parameters with new defaults. + if ( + (layer.type === "image" || layer.type === "video") && + params.transparentBounds === undefined + ) { + params.transparentBounds = false + } + if (layer.type === "ascii" && typeof params.fontWeight === "string") { params.fontWeight = LEGACY_ASCII_FONT_WEIGHTS[params.fontWeight] ?? 400 } diff --git a/src/renderer/blend-modes.ts b/src/renderer/blend-modes.ts index 9a67bc74..df5a2d88 100644 --- a/src/renderer/blend-modes.ts +++ b/src/renderer/blend-modes.ts @@ -161,6 +161,8 @@ export type MaskNodeConfig = { source: string } +export type CompositionRole = "source" | "effect" + 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,33 @@ 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))) + 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/src/renderer/composition-tree.ts b/src/renderer/composition-tree.ts new file mode 100644 index 00000000..c206bc5a --- /dev/null +++ b/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/src/renderer/contracts.ts b/src/renderer/contracts.ts index e14fb539..344b51f3 100644 --- a/src/renderer/contracts.ts +++ b/src/renderer/contracts.ts @@ -5,6 +5,7 @@ import { import { cloneParameterValues } from "@/lib/editor/parameter-schema" import { evaluateTimelineForLayers } from "@/lib/editor/timeline/evaluate" import { createProjectClock } from "@/renderer/project-clock" +import type { CompositionNode } from "@/renderer/composition-tree" import type { EditorAsset, EditorLayer, @@ -32,7 +33,7 @@ export interface RenderableLayerPass { export interface RendererFrame { clock: ProjectClock cropAspectRatio: number | null - layers: RenderableLayerPass[] + layers: CompositionNode[] logicalSize: Size outputSize: Size pixelRatio: number diff --git a/src/renderer/create-webgpu-renderer.ts b/src/renderer/create-webgpu-renderer.ts index 98580c32..e5687971 100644 --- a/src/renderer/create-webgpu-renderer.ts +++ b/src/renderer/create-webgpu-renderer.ts @@ -3,6 +3,7 @@ import * as THREE from "three/webgpu" import { recordDeviceDiagnostics } from "@/lib/webgpu-diagnostics" import type { EditorRenderer, RendererFrame } from "@/renderer/contracts" import { PipelineManager } from "@/renderer/pipeline-manager" +import { reverseComposition } from "@/renderer/composition-tree" import { browserSupportsWebGPU } from "@/renderer/webgpu-support" import type { Size } from "@/types/editor" @@ -77,7 +78,7 @@ export async function createWebGPURenderer( pipeline.updateBackgroundColor(frame.sceneConfig.backgroundColor) pipeline.updateSceneConfig(frame.sceneConfig) pipeline.updateOutputCropAspectRatio(frame.cropAspectRatio) - pipeline.syncLayers([...frame.layers].reverse()) + pipeline.syncLayers(reverseComposition(frame.layers)) pipeline.render( frame.clock.time, frame.clock.delta, diff --git a/src/renderer/group-pass.ts b/src/renderer/group-pass.ts new file mode 100644 index 00000000..62f5dcce --- /dev/null +++ b/src/renderer/group-pass.ts @@ -0,0 +1,124 @@ +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 async prepareForExportFrame( + time: number, + loop: boolean + ): Promise { + await Promise.all( + this.children + .filter(this.isActive) + .map((pass) => pass.prepareForExportFrame(time, loop)) + ) + } + + 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/src/renderer/media-pass.ts b/src/renderer/media-pass.ts index 73e3255c..e250ee8e 100644 --- a/src/renderer/media-pass.ts +++ b/src/renderer/media-pass.ts @@ -28,6 +28,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 @@ -47,6 +48,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) @@ -114,6 +116,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 @@ -212,7 +215,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/src/renderer/pass-failure.ts b/src/renderer/pass-failure.ts index 766e0513..5279c6db 100644 --- a/src/renderer/pass-failure.ts +++ b/src/renderer/pass-failure.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/nextjs" import { useLayerStore } from "@/store/layer-store" import type { EditorLayer } from "@/types/editor" -export type LayerType = EditorLayer["type"] +export type LayerType = EditorLayer["type"] | "group" export type PassFailureSurface = | "build-effect-node" diff --git a/src/renderer/pass-node.ts b/src/renderer/pass-node.ts index ace5a785..9c5b4b33 100644 --- a/src/renderer/pass-node.ts +++ b/src/renderer/pass-node.ts @@ -13,7 +13,7 @@ import { vec3, vec4, } from "three/tsl" -import { buildBlendNode } from "@/renderer/blend-modes" +import { buildBlendNode, type CompositionRole } from "@/renderer/blend-modes" import type { BloomCompositor } from "@/renderer/dual-filter-bloom" import type { LayerCompositeMode, @@ -45,6 +45,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" @@ -55,7 +56,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) @@ -104,6 +108,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 @@ -229,6 +240,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() @@ -288,6 +300,7 @@ export class PassNode { source: this.maskSource, } : undefined, + this.compositionRole, ) as Node } diff --git a/src/renderer/pipeline-manager.ts b/src/renderer/pipeline-manager.ts index e51831d6..c9a327c8 100644 --- a/src/renderer/pipeline-manager.ts +++ b/src/renderer/pipeline-manager.ts @@ -1,3 +1,9 @@ +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 { isSvgMediaSource } from "@/lib/editor/media-file" @@ -139,6 +145,7 @@ export class PipelineManager { private passFailures = new Map() private readonly strictPassFailures: boolean private pendingMediaLoads = new Set() + private reportedStrictFailures = new WeakSet() private cachedActivePasses: LayerPassNode[] = [] private activePassesDirty = true private dirty = true @@ -198,6 +205,7 @@ export class PipelineManager { const blitUv = vec2(uv().x, float(1).sub(uv().y)) this.blitInputNode = tslTexture(new THREE.Texture(), 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), @@ -207,8 +215,11 @@ export class PipelineManager { this.blitScene.add(blitMesh) } - syncLayers(layers: RenderableLayerPass[]): void { - const incomingIds = new Set(layers.map((layer) => layer.layer.id)) + syncLayers(layers: CompositionNode[]): void { + const getId = (node: CompositionNode) => + isCompositionGroup(node) ? node.id : node.layer.id + const flattened = flattenComposition(layers, (node) => node.layer.id) + const incomingIds = new Set(flattened.map(getId)) for (const [layerId, pass] of this.passMap) { if (incomingIds.has(layerId)) { @@ -217,6 +228,7 @@ export class PipelineManager { pass.dispose() this.passMap.delete(layerId) + this.pendingMediaLoads.delete(layerId) this.layerSignatures.delete(layerId) this.compilingPasses.delete(layerId) this.compiledVersions.delete(layerId) @@ -225,17 +237,33 @@ export class PipelineManager { this.markDirty() } - const orderedPasses: LayerPassNode[] = [] - - for (const renderableLayer of layers) { - const layerId = renderableLayer.layer.id - const signature = createLayerSignature(renderableLayer) + 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) - this.layerTypes.set(layerId, renderableLayer.layer.type) - + if (pass && pass instanceof GroupPass !== group) { + pass.dispose() + this.passMap.delete(layerId) + this.pendingMediaLoads.delete(layerId) + this.layerSignatures.delete(layerId) + this.compilingPasses.delete(layerId) + this.compiledVersions.delete(layerId) + pass = undefined + } + this.layerTypes.set(layerId, group ? "group" : node.layer.type) + const created = !pass if (!pass) { - pass = this.createPass(renderableLayer.layer) + pass = group + ? new GroupPass( + layerId, + (child) => this.isActive(child), + (...args) => this.renderPass(...args) + ) + : this.createPass(node.layer) pass.resize(this.width, this.height) pass.updateLogicalSize(this.logicalWidth, this.logicalHeight) this.passMap.set(layerId, pass) @@ -245,20 +273,38 @@ export class PipelineManager { if (this.layerSignatures.get(layerId) !== signature) { const versionBefore = pass.getMaterialVersion() this.layerSignatures.set(layerId, signature) - this.applyLayerState(pass, renderableLayer) + if (group) { + pass.enabled = node.visible + pass.updateOpacity(clampUnit(node.opacity)) + pass.updateBlendMode(node.blendMode) + pass.flushColorNode() + } else { + this.applyLayerState(pass, node) + } this.markDirty() - if (pass.getMaterialVersion() !== versionBefore) { - // Recover on a material rebuild, not on any signature change: keyframed - // and audio-driven values change the signature every frame, which would - // reset the failure count before it could ever throttle. + if ((created && group) || pass.getMaterialVersion() !== versionBefore) { + // Animated uniform changes must not reset repeated render failures. + // Only a new pass or rebuilt material re-enables a dropped pass. this.clearPassFailure(layerId) 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.markDirty() + } } + const orderedPasses = layers.map((node) => this.passMap.get(getId(node))!) if ( orderedPasses.length !== this.passes.length || @@ -271,12 +317,8 @@ export class PipelineManager { render(time: number, delta: number, timelineTime = time): boolean { if (this.activePassesDirty) { - this.cachedActivePasses = this.passes.filter( - (pass) => - pass.enabled && - !this.isPassDisabled(pass.layerId) && - (!this.compilingPasses.has(pass.layerId) || - this.compiledVersions.has(pass.layerId)) + this.cachedActivePasses = this.passes.filter((pass) => + this.isActive(pass) ) this.activePassesDirty = false } @@ -304,43 +346,17 @@ export class PipelineManager { let writeTarget = this.rtB for (const pass of activePasses) { - try { - ;( - pass.render as ( - renderer: THREE.WebGPURenderer, - inputTexture: THREE.Texture, - outputTarget: THREE.WebGLRenderTarget, - time: number, - delta: number, - timelineTime: number - ) => void - )( - this.renderer, + if ( + !this.renderPass( + pass, readTarget.texture, writeTarget, time, delta, timelineTime ) - } catch (error) { - // Exports must fail loudly: dropping a layer would ship a frame that - // looks fine but is wrong. Still report, so the abort is diagnosable. - if (this.strictPassFailures) { - reportPassFailure( - this.layerTypes.get(pass.layerId), - pass.layerId, - "pass-render", - error - ) - throw error - } - - this.handlePassRenderFailure(pass.layerId, error) - // Skip the swap: the failed pass contributes nothing. + ) continue - } - - this.passFailures.delete(pass.layerId) const previousRead = readTarget readTarget = writeTarget @@ -475,6 +491,13 @@ export class PipelineManager { renderableLayer: RenderableLayerPass ): void { pass.enabled = renderableLayer.layer.visible + pass.updateCompositionRole( + renderableLayer.layer.kind === "effect" || + (renderableLayer.layer.type === "custom-shader" && + renderableLayer.params.effectMode === true) + ? "effect" + : "source" + ) pass.updateOpacity(clampUnit(renderableLayer.layer.opacity)) pass.updateBlendMode(renderableLayer.layer.blendMode) pass.updateCompositeMode(renderableLayer.layer.compositeMode) @@ -514,10 +537,12 @@ export class PipelineManager { width: asset.width, }) .then(() => { + if (this.passMap.get(pass.layerId) !== pass) return setLayerMediaError(pass.layerId, null) this.markDirty() }) .catch(() => { + if (this.passMap.get(pass.layerId) !== pass) return setLayerMediaError( pass.layerId, describeMediaLoadFailure(asset.fileName) @@ -525,6 +550,7 @@ export class PipelineManager { this.markDirty() }) .finally(() => { + if (this.passMap.get(pass.layerId) !== pass) return this.pendingMediaLoads.delete(pass.layerId) }) } else { @@ -557,6 +583,60 @@ export class PipelineManager { } } + private isActive(pass: PassNode): boolean { + return ( + pass.enabled && + !this.isPassDisabled(pass.layerId) && + (!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: number + ): boolean { + try { + ;( + pass.render as ( + renderer: THREE.WebGPURenderer, + input: THREE.Texture, + output: THREE.WebGLRenderTarget, + time: number, + delta: number, + timelineTime: number + ) => void + )(this.renderer, input, output, time, delta, timelineTime) + } catch (error) { + if (this.strictPassFailures) { + const alreadyReported = + typeof error === "object" && + error !== null && + this.reportedStrictFailures.has(error) + if (!alreadyReported) { + if (typeof error === "object" && error !== null) { + this.reportedStrictFailures.add(error) + } + reportPassFailure( + this.layerTypes.get(pass.layerId), + pass.layerId, + "pass-render", + error + ) + } + throw error + } + this.handlePassRenderFailure(pass.layerId, error) + return false + } + this.passFailures.delete(pass.layerId) + return true + } + private isPassDisabled(layerId: string): boolean { return (this.passFailures.get(layerId)?.total ?? 0) >= MAX_PASS_FAILURES } @@ -606,11 +686,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.markDirty() }) .catch((error: unknown) => { + if (this.passMap.get(pass.layerId) !== pass) return // Keep the delete: dropping it wedges hasPendingCompilations(). this.compilingPasses.delete(pass.layerId) reportPassFailure( diff --git a/src/renderer/scene-post-process.ts b/src/renderer/scene-post-process.ts index d2d09e49..709d259b 100644 --- a/src/renderer/scene-post-process.ts +++ b/src/renderer/scene-post-process.ts @@ -167,6 +167,7 @@ export class ScenePostProcess { this.scene = new THREE.Scene() this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) this.material = new THREE.MeshBasicNodeMaterial() + this.material.blending = THREE.NoBlending const renderTargetUv = vec2(uv().x, float(1).sub(uv().y)) this.inputNode = tslTexture(new THREE.Texture(), renderTargetUv) @@ -550,6 +551,6 @@ export class ScenePostProcess { // Final clamp color = clamp(color, 0, 1) - return vec4(color, float(1)) + return vec4(color, input.a) } } diff --git a/src/types/three-webgpu.d.ts b/src/types/three-webgpu.d.ts index 8740a737..122555f4 100644 --- a/src/types/three-webgpu.d.ts +++ b/src/types/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/tests/composition/README.md b/tests/composition/README.md new file mode 100644 index 00000000..6a4b05c2 --- /dev/null +++ b/tests/composition/README.md @@ -0,0 +1,90 @@ +# V3 composition baselines + +Regression coverage for roadmap steps 1.1–1.3. The first PR establishes legacy baselines; its stacked successors add transparent media bounds, alpha-aware source/effect composition, and internal isolated group rendering. Transparent scene/export backgrounds, editor group controls/persistence, and new masks remain outstanding. + +Integration branch: `git-chad/shader-lab-v3-plan`. Parent PR: [#150](https://github.com/basementstudio/shader-lab/pull/150). The first child, [#151](https://github.com/basementstudio/shader-lab/pull/151), targets integration. Each subsequent stacked PR targets the preceding feature branch, never `main`. + +## Run + +```sh +bun install --frozen-lockfile +bunx playwright install chromium +bun run test:composition +``` + +The supported baseline runner is macOS, including the dedicated `macos-14` CI job. Linux Chromium/SwiftShader currently drops its GPU instance during texture-backed shader compilation (`Instance dropped in popErrorScope`); Linux support is not verified. The existing build, lint, and type checks remain on Linux. + +The runner serves a bundled harness on an ephemeral loopback port and launches the locked Playwright Chromium with an explicitly selected SwiftShader WebGPU adapter. No app server, account, or external services are required. Missing WebGPU, browser errors, shader compilation errors, missing baselines, and mismatches fail the command; tests do not silently skip or fall back to WebGL. + +Actual PNGs are saved in `.context/composition-test/`. Run `bun run test:composition --update` only when deliberately capturing a reviewed baseline. Never refresh existing legacy expectations just to make an alpha change pass. + +## Frozen references + +Captured before renderer changes, from the renderer at `6f39a88` (the pre-V3 `origin/main` revision). Fixture JSON files retain explicit version-6 settings instead of regenerating from layer defaults. The four render fixtures are synthetic saved projects created for regression coverage, not user-supplied artworks. They use a local SVG with a transparent cutout and soft alpha edge, and the bundled Geist Mono font. + +| Fixture | What it protects | +| --- | --- | +| `legacy-text-mask` | Existing text in luminance-mask mode, including its opaque black surroundings | +| `solid-text-background` | Deliberate solid text backgrounds and partial layer opacity | +| `transparent-text-screen` | Existing transparent text with an explicitly selected Screen blend | +| `contained-image-halftone` | Contained media, current black letterboxing, and partial-strength halftone | + +Each fixture renders through the editor renderer, compares preview with PNG export, and reopens serialized JSON into a new renderer/device. PNG comparisons allow at most two byte values per channel for backend rounding; no pixels may exceed that tolerance. Reopen and preview/export comparisons are exact within a run. Images are also checked for opaque alpha and nontrivial content. + +`legacy-blends.json` freezes 136 GPU samples across the editor and exported runtime: all 16 blend modes at three opacities, plus five mask sources, two mask modes, and inversion. The shaders are evaluated into float render targets; both implementations must match, and reference values have a tolerance of `0.00001`. These are compatibility expectations, not endorsements of the old alpha semantics. + +`existing-default-project.json` is an unmodified copy of the repository's existing saved project. Parsing, serialization, reopening, viewer hydration, and the actual editor hydration/save path must retain its nine layers, two bundled assets, blend modes, masks, opacity, and parameter values. The editor check also verifies scene settings, composition dimensions, selection, timeline, audio, and the scene-replacement signal. Its video/audio and full effect stack are **not** rendered by this initial suite. + +Editor hydration starts from deliberately different store state. A separate in-memory variant adds an opacity track with keyframes, tracks targeting a missing layer and parameter, and an invalid layer selection. It verifies that hydration preserves valid animation, prunes invalid tracks, clears previous timeline selection/playhead state, and saves only the surviving track. The frozen project fixture remains unchanged. + +## Transparent media bounds + +New image and video layers expose **Transparent Borders** when Fit is **Contain**. It defaults on; disabling it restores black borders. Saved layers without `transparentBounds` are migrated to `false` before new defaults are applied. Runtime configs that omit it also keep black borders. Cover mode is unchanged, including its existing edge sampling. No saved fixture or visual baseline is refreshed for this change. + +`media-bounds.mjs` renders the editor and runtime MediaPass with both the SVG and bundled video over a colored input texture. It checks legacy/missing, explicit solid, transparent, partial-opacity, scaled/offset, and Cover cases. It also checks new-layer defaults, migration, save/reopen, shader-config export, and preview/PNG export against explicit pixel expectations. Rendered transparent/solid examples are saved alongside the baseline artifacts. + +This change reveals lower content at empty media bounds; it does not make the scene canvas or exported PNG background transparent. The existing scene background, mask semantics, and global effect scope remain unchanged. + +## Confirmed alpha boundaries + +### Source/effect composition foundation + +The shared compositor now treats source layers as straight-alpha source-over and effect layers as coverage-preserving color filters. Source blending follows the [W3C general blending/compositing formula](https://www.w3.org/TR/compositing-1/#blending), including backdrop coverage when applying blend modes. Zero-coverage output is finite and tiny nonzero coverage does not darken RGB. The opaque-backdrop path retains the previous RGB expression. + +Effect output alpha continues to control effect strength (`layer opacity × effect alpha`); it does not add new coverage over the input. The layer kind determines this role, with custom shaders in Effect Mode treated as effects. This is the initial policy: spatial effects still need per-effect evaluation of displaced/blurred coverage before claiming complete alpha support. + +Pass materials (including asynchronously replaced materials), texture copies, and scene grading retain alpha instead of forcing it opaque. Materials use `NoBlending` because the shader has already computed the final pixel; another GPU blend would apply alpha twice. Internal textures use straight RGB and alpha. Runtime callers supplying premultiplied input need to convert it to straight alpha first. + +`alpha-compositing.mjs` checks analytic source-over examples, all blend modes against empty backdrops, finite empty pixels, tiny alpha, source/effect opacity, adjacent transparent/opaque pixels, repeated filtering, actual pass material creation/replacement, scene grading, and the headless runtime pipeline. It checks source/effect role selection using real gradient and posterize layers over a translucent external texture. All legacy PNGs and mask samples remain unchanged. + +These findings come from source inspection and the limited GPU checks above, not a completed end-to-end alpha audit. + +| Boundary | Current behavior | Required follow-up | +| --- | --- | --- | +| `blend-modes.ts` in editor and runtime | Sources combine coverage; effects preserve input coverage. Legacy masks still darken RGB and return alpha 1 | Introduce true coverage masks with a compatibility path for saved masks | +| `pass-node.ts` in both renderers | Roles are derived from layer kind/Effect Mode; initial and replacement materials preserve computed straight alpha | Audit individual effect algorithms, particularly spatial effects, as group composition is introduced | +| `media-pass.ts` | Contain mode now supports transparent out-of-bounds samples, defaulting on for new layers; absent settings retain black | Broader alpha composition remains separate from this source-boundary fix | +| `pipeline-manager.ts` | The root begins over an opaque base (or a runtime input texture); internal groups render children against independent transparent targets | Connect groups to editor organization, undo, persistence, and public runtime configs; keep the scene background a deliberate choice | +| `scene-post-process.ts` | Scene color adjustments now retain input alpha | Verify grading together with future transparent canvas/export support | +| Canvas renderer creation | Editor and runtime use `alpha: false`; editor clears to alpha 1 | Carry alpha through preview/export where supported, including texture output | +| Text creation | New text defaults to mask mode and background alpha 1 | Change only new-layer defaults in phase 2; preserve saved text settings | + +The source/effect distinction prevents repeated filtering from increasing coverage. True masks still change the visual meaning of existing saved masks and require an explicit compatibility path before switching the editor to transparent targets. + +## Next implementation slice + +1. Connect the internal group render tree to editor group creation, organization, collapse, visibility, opacity, undo, save/reopen, and shader-config export. +2. Define a persisted compatibility strategy for true coverage masks; extend tests with soft group masks over colored lower layers and per-effect spatial behavior. +3. Carry alpha through transparent scene backgrounds, preview, and supported exports while keeping these legacy fixtures stable. + +## Isolated group renderer foundation + +The internal `RendererFrame.layers` contract accepts a tree of existing renderable layers and `CompositionGroup` nodes. Frames use top-first sidebar order at every level; pipeline synchronization reverses every sibling list into paint order. Existing project files and public `ShaderLabConfig` values still contain flat layers. This is the rendering foundation, not delivery of editor groups or the complete roadmap step 1.3. + +Each group clears its own targets to transparent, processes only its children, and composites the combined result into its parent. Group opacity and blend mode apply once to that result. Empty and effect-only groups do not modify the parent; hidden parents suppress rendering and export preparation throughout their subtree. Children retain their pass instances when reparented, preserving media/simulation state. Removed groups dispose their own targets and material without disposing surviving children. + +Nesting is bounded to eight group levels. Duplicate IDs and excessive depth are rejected before pipeline synchronization mutates live passes. Each group owns two full-resolution RGBA16F targets (about 32 MiB at 1920×1080), reused across frames and resized with the output. This is not an unlimited group-count or performance guarantee; pooling and representative large-scene measurements remain follow-up work. + +`groups.mjs` exercises actual editor/runtime pipelines with gradient sources and threshold effects, covering effect isolation, overlap, group versus child opacity, nested/sibling scope, visibility, ordering, reparenting, resize, cleanup, animation discovery, export preparation, and external runtime input alpha. The editor entry-point test renders a nested photographic group with transparent bounds and compares preview with PNG export, saving `isolated-groups.png`. Existing flat saved projects and frozen baselines remain unchanged. Coverage masks, persisted groups, and undo are not yet tested or implemented by this slice. + +Still outstanding for phase 1: the fourteen selected artistic references, broader photographs/portraits/objects and video captures, complete runtime scene/export coverage, group/mask behavior, and the short 3D feasibility checks. This suite is a starting point, not phase-1 acceptance. diff --git a/tests/composition/alpha-compositing.mjs b/tests/composition/alpha-compositing.mjs new file mode 100644 index 00000000..b20ad15d --- /dev/null +++ b/tests/composition/alpha-compositing.mjs @@ -0,0 +1,368 @@ +import { buildBlendNode as runtimeBlend } from "@runtime/renderer/blend-modes" +import { buildRendererFrame as runtimeFrame } from "@runtime/renderer/contracts" +import { createHeadlessRenderer } from "@runtime/renderer/create-headless-renderer" +import { PassNode as RuntimePass } from "@runtime/renderer/pass-node" +import { float, texture, vec4 } from "three/tsl" +import * as THREE from "three/webgpu" +import { createLayer } from "@/lib/editor/layers" +import { buildBlendNode as editorBlend } from "@/renderer/blend-modes" +import { PassNode as EditorPass } from "@/renderer/pass-node" +import { ScenePostProcess } from "@/renderer/scene-post-process" +import { BLEND_MODES, DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assertPixel(actual, expected, label, tolerance = 0.00001) { + if ( + actual.some( + (value, index) => + !Number.isFinite(value) || Math.abs(value - expected[index]) > tolerance + ) + ) { + throw new Error(`${label}: expected ${expected}, received ${actual}`) + } +} + +export async function checkAlphaCompositing() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const target = new THREE.RenderTarget(1, 1, { + type: THREE.FloatType, + depthBuffer: false, + }) + const input = new THREE.DataTexture( + new Float32Array([0, 0, 1, 0.5]), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + input.needsUpdate = true + const material = new THREE.MeshBasicNodeMaterial({ + blending: THREE.NoBlending, + }) + const geometry = new THREE.PlaneGeometry(2, 2) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(geometry, material)) + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + const read = async () => + Array.from(await renderer.readRenderTargetPixelsAsync(target, 0, 0, 1, 1)) + const renderNode = async (node) => { + material.colorNode = node + material.needsUpdate = true + renderer.setRenderTarget(target) + renderer.render(scene, camera) + return read() + } + let samples = 0 + try { + for (const [name, blend] of [ + ["editor", editorBlend], + ["runtime", runtimeBlend], + ]) { + const cases = [ + { + label: "half red over half blue", + mode: "normal", + opacity: 1, + expected: [2 / 3, 0, 1 / 3, 0.75], + }, + { + label: "multiply blends only in overlap", + mode: "multiply", + opacity: 1, + expected: [1 / 3, 0, 1 / 3, 0.75], + }, + { + label: "screen blends only in overlap", + mode: "screen", + opacity: 1, + expected: [2 / 3, 0, 2 / 3, 0.75], + }, + { + label: "source opacity reduces coverage", + mode: "normal", + opacity: 0.5, + expected: [0.4, 0, 0.6, 0.625], + }, + { + label: "zero opacity preserves backdrop", + mode: "normal", + opacity: 0, + expected: [0, 0, 1, 0.5], + }, + { + label: "negative opacity is clamped", + mode: "normal", + opacity: -1, + expected: [0, 0, 1, 0.5], + }, + { + label: "excess opacity is clamped", + mode: "normal", + opacity: 2, + expected: [2 / 3, 0, 1 / 3, 0.75], + }, + ] + for (const test of cases) { + assertPixel( + await renderNode( + blend( + test.mode, + vec4(0, 0, 1, 0.5), + vec4(1, 0, 0, 0.5), + float(test.opacity) + ) + ), + test.expected, + `${name}: ${test.label}` + ) + samples++ + } + for (const mode of BLEND_MODES) { + assertPixel( + await renderNode( + blend( + mode, + vec4(0.1, 0.9, 0.3, 0), + vec4(0.8, 0.2, 0.4, 0.5), + float(0.4) + ) + ), + [0.8, 0.2, 0.4, 0.2], + `${name}/${mode}: hidden backdrop RGB cannot tint the source` + ) + assertPixel( + await renderNode( + blend( + mode, + vec4(0.1, 0.9, 0.3, 0), + vec4(0.8, 0.2, 0.4, 0), + float(1) + ) + ), + [0, 0, 0, 0], + `${name}/${mode}: empty output stays empty and finite` + ) + samples += 2 + } + assertPixel( + await renderNode( + blend("normal", vec4(0, 0, 0, 0), vec4(1, 0.5, 0.25, 1e-8), float(1)) + ), + [1, 0.5, 0.25, 1e-8], + `${name}: tiny coverage does not darken straight RGB`, + 1e-7 + ) + const first = blend( + "normal", + vec4(0, 0, 1, 0.5), + vec4(1, 0, 0, 1), + float(0.5), + "filter", + undefined, + "effect" + ) + assertPixel( + await renderNode(first), + [0.5, 0, 0.5, 0.5], + `${name}: effects preserve input coverage` + ) + assertPixel( + await renderNode( + blend( + "normal", + first, + vec4(1, 0, 0, 1), + float(0.5), + "filter", + undefined, + "effect" + ) + ), + [0.75, 0, 0.25, 0.5], + `${name}: repeated effects do not inflate coverage` + ) + assertPixel( + await renderNode( + blend( + "normal", + vec4(0, 0, 1, 0.5), + vec4(1, 0, 0, 0.5), + float(1), + "filter", + undefined, + "effect" + ) + ), + [0.5, 0, 0.5, 0.5], + `${name}: effect alpha retains its strength semantics` + ) + samples += 4 + + // A transparent pixel and an opaque pixel must coexist in one target; + // this cannot be achieved by lowering global material opacity. + const stripe = new THREE.DataTexture( + new Float32Array([0, 1, 0, 0, 0, 0, 1, 1]), + 2, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + stripe.needsUpdate = true + target.setSize(2, 1) + try { + await renderNode( + blend("normal", texture(stripe), vec4(1, 0, 0, 0.5), float(1)) + ) + const sourcePixels = Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 0, 2, 1) + ) + assertPixel( + sourcePixels, + [1, 0, 0, 0.5, 0.5, 0, 0.5, 1], + `${name}: mixed source coverage in one target` + ) + await renderNode( + blend( + "normal", + texture(stripe), + vec4(0, 1, 0, 1), + float(0.5), + "filter", + undefined, + "effect" + ) + ) + const effectPixels = Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 0, 2, 1) + ) + assertPixel( + [effectPixels[3], effectPixels[7]], + [0, 1], + `${name}: effect retains both holes and opaque pixels` + ) + samples += 2 + } finally { + stripe.dispose() + target.setSize(1, 1) + } + } + + for (const [name, Pass] of [ + ["editor", EditorPass], + ["runtime", RuntimePass], + ]) { + // Exercise the real initial material and asynchronous replacement path, + // not just the blend expression in a hand-configured test material. + class SolidPass extends Pass { + buildEffectNode() { + return vec4(1, 0, 0, 0.5) + } + replaceEffect() { + return this.swapEffectNodeAsync(vec4(0, 1, 0, 0.5)) + } + } + const pass = new SolidPass(`${name}-alpha`) + try { + pass.updateCompositionRole("source") + pass.flushColorNode() + pass.render(renderer, input, target, 0, 0) + assertPixel( + await read(), + [2 / 3, 0, 1 / 3, 0.75], + `${name}: pass material retains composed alpha` + ) + if (!(await pass.replaceEffect())) + throw new Error(`${name}: replacement material was not installed`) + pass.render(renderer, input, target, 0, 0) + assertPixel( + await read(), + [0, 2 / 3, 1 / 3, 0.75], + `${name}: async material retains composed alpha` + ) + pass.updateCompositionRole("effect") + pass.flushColorNode() + pass.render(renderer, input, target, 0, 0) + assertPixel( + await read(), + [0, 0.5, 0.5, 0.5], + `${name}: role changes rebuild composition` + ) + samples += 3 + } finally { + pass.dispose() + } + } + + const grade = new ScenePostProcess() + try { + grade.update({ ...DEFAULT_SCENE_CONFIG, brightness: 0.2 }) + grade.render(renderer, input, target) + const graded = await read() + if (graded[0] < 0.1 || graded[1] < 0.1) + throw new Error("Scene grading did not run") + assertPixel([graded[3]], [0.5], "Scene grading preserves alpha") + samples++ + } finally { + grade.dispose() + } + + // Test the public headless path: it copies the external input and derives + // source/effect roles from layer configuration before applying passes. + const compilations = [] + const compileAsync = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const promise = compileAsync(...args) + compilations.push(promise) + return promise + } + const headless = createHeadlessRenderer({ + renderer, + size: { width: 1, height: 1 }, + }) + try { + await headless.initialize() + for (const [type, expectedAlpha] of [ + [null, 0.5], + ["posterize", 0.5], + ["gradient", 0.75], + ]) { + const layers = type ? [{ ...createLayer(type), opacity: 0.5 }] : [] + const frame = runtimeFrame( + { layers, timeline: { duration: 1, loop: true, tracks: [] } }, + 0, + 0, + 1, + { width: 1, height: 1 } + ) + headless.render(frame, input) + await Promise.all(compilations.splice(0)) + const output = headless.render(frame, input) + if (!output) throw new Error("Headless pipeline produced no texture") + const rgba = await renderNode(texture(output)) + assertPixel( + [rgba[3]], + [expectedAlpha], + `Runtime texture pipeline: ${type ?? "input copy"}` + ) + if (!type) + assertPixel( + rgba, + [0, 0, 1, 0.5], + "Runtime input copy preserves straight RGB" + ) + samples++ + } + } finally { + headless.dispose() + renderer.compileAsync = compileAsync + } + return samples + } finally { + input.dispose() + target.dispose() + material.dispose() + geometry.dispose() + renderer.dispose() + } +} diff --git a/tests/composition/baselines/contained-image-halftone.png b/tests/composition/baselines/contained-image-halftone.png new file mode 100644 index 00000000..7f2640e0 Binary files /dev/null and b/tests/composition/baselines/contained-image-halftone.png differ diff --git a/tests/composition/baselines/legacy-blends.json b/tests/composition/baselines/legacy-blends.json new file mode 100644 index 00000000..d8c1a9f8 --- /dev/null +++ b/tests/composition/baselines/legacy-blends.json @@ -0,0 +1,1538 @@ +[ + { + "implementation": "editor", + "mode": "normal", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "normal", + "opacity": 0.35, + "rgba": [ + 0.3050000071525574, + 0.5475000143051147, + 0.7599999308586121, + 1 + ] + }, + { + "implementation": "editor", + "mode": "normal", + "opacity": 1, + "rgba": [ + 0.5, + 0.45000001788139343, + 0.5, + 1 + ] + }, + { + "implementation": "editor", + "mode": "multiply", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "multiply", + "opacity": 0.35, + "rgba": [ + 0.19300000369548798, + 0.5264999866485596, + 0.7582499384880066, + 1 + ] + }, + { + "implementation": "editor", + "mode": "multiply", + "opacity": 1, + "rgba": [ + 0.18000000715255737, + 0.39000001549720764, + 0.4949999749660492, + 1 + ] + }, + { + "implementation": "editor", + "mode": "screen", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "screen", + "opacity": 0.35, + "rgba": [ + 0.31200000643730164, + 0.6209999918937683, + 0.9017499685287476, + 1 + ] + }, + { + "implementation": "editor", + "mode": "screen", + "opacity": 1, + "rgba": [ + 0.5200000405311584, + 0.6600000262260437, + 0.9049999713897705, + 1 + ] + }, + { + "implementation": "editor", + "mode": "overlay", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "overlay", + "opacity": 0.35, + "rgba": [ + 0.2840000092983246, + 0.5720000267028809, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "editor", + "mode": "overlay", + "opacity": 1, + "rgba": [ + 0.4399999976158142, + 0.5200000405311584, + 0.85999995470047, + 1 + ] + }, + { + "implementation": "editor", + "mode": "darken", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "darken", + "opacity": 0.35, + "rgba": [ + 0.20000000298023224, + 0.5475000143051147, + 0.7599999308586121, + 1 + ] + }, + { + "implementation": "editor", + "mode": "darken", + "opacity": 1, + "rgba": [ + 0.20000000298023224, + 0.45000001788139343, + 0.5, + 1 + ] + }, + { + "implementation": "editor", + "mode": "lighten", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "lighten", + "opacity": 0.35, + "rgba": [ + 0.3050000071525574, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "lighten", + "opacity": 1, + "rgba": [ + 0.5, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-dodge", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-dodge", + "opacity": 0.35, + "rgba": [ + 0.3400000035762787, + 0.6449999809265137, + 0.9174999594688416, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-dodge", + "opacity": 1, + "rgba": [ + 0.6000000238418579, + 0.7285714745521545, + 0.949999988079071, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-burn", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-burn", + "opacity": 0.35, + "rgba": [ + 0.16500000655651093, + 0.4950000047683716, + 0.7424999475479126, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color-burn", + "opacity": 1, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hard-light", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hard-light", + "opacity": 0.35, + "rgba": [ + 0.2840000092983246, + 0.5720000267028809, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hard-light", + "opacity": 1, + "rgba": [ + 0.4399999976158142, + 0.5200000405311584, + 0.85999995470047, + 1 + ] + }, + { + "implementation": "editor", + "mode": "soft-light", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "soft-light", + "opacity": 0.35, + "rgba": [ + 0.22595743834972382, + 0.5877782702445984, + 0.8931843042373657, + 1 + ] + }, + { + "implementation": "editor", + "mode": "soft-light", + "opacity": 1, + "rgba": [ + 0.274164080619812, + 0.5650807023048401, + 0.8805266618728638, + 1 + ] + }, + { + "implementation": "editor", + "mode": "difference", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "difference", + "opacity": 0.35, + "rgba": [ + 0.27000001072883606, + 0.5475000143051147, + 0.8824999332427979, + 1 + ] + }, + { + "implementation": "editor", + "mode": "difference", + "opacity": 1, + "rgba": [ + 0.4000000059604645, + 0.45000001788139343, + 0.8499999642372131, + 1 + ] + }, + { + "implementation": "editor", + "mode": "exclusion", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "exclusion", + "opacity": 0.35, + "rgba": [ + 0.2839999794960022, + 0.5895000100135803, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "editor", + "mode": "exclusion", + "opacity": 1, + "rgba": [ + 0.4399999678134918, + 0.5700000524520874, + 0.8600000143051147, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hue", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hue", + "opacity": 0.35, + "rgba": [ + 0.33033299446105957, + 0.5728330016136169, + 0.785332977771759, + 1 + ] + }, + { + "implementation": "editor", + "mode": "hue", + "opacity": 1, + "rgba": [ + 0.572380006313324, + 0.522379994392395, + 0.572380006313324, + 1 + ] + }, + { + "implementation": "editor", + "mode": "saturation", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "saturation", + "opacity": 0.35, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "saturation", + "opacity": 1, + "rgba": [ + 0.19999998807907104, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color", + "opacity": 0.35, + "rgba": [ + 0.33033299446105957, + 0.5728330016136169, + 0.785332977771759, + 1 + ] + }, + { + "implementation": "editor", + "mode": "color", + "opacity": 1, + "rgba": [ + 0.572380006313324, + 0.522379994392395, + 0.572380006313324, + 1 + ] + }, + { + "implementation": "editor", + "mode": "luminosity", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "mode": "luminosity", + "opacity": 0.35, + "rgba": [ + 0.17466700077056885, + 0.5746670365333557, + 0.8746669888496399, + 1 + ] + }, + { + "implementation": "editor", + "mode": "luminosity", + "opacity": 1, + "rgba": [ + 0.12761999666690826, + 0.5276200175285339, + 0.8276199698448181, + 1 + ] + }, + { + "implementation": "editor", + "source": "alpha", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "editor", + "source": "alpha", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "editor", + "source": "alpha", + "mode": "stencil", + "invert": false, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "alpha", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "red", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.1600000113248825, + 0.48000001907348633, + 0.7199999690055847, + 1 + ] + }, + { + "implementation": "editor", + "source": "red", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.03999999910593033, + 0.11999999731779099, + 0.17999997735023499, + 1 + ] + }, + { + "implementation": "editor", + "source": "red", + "mode": "stencil", + "invert": false, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "red", + "mode": "stencil", + "invert": true, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "editor", + "source": "green", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.06000000238418579, + 0.18000000715255737, + 0.27000001072883606, + 1 + ] + }, + { + "implementation": "editor", + "source": "green", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.14000000059604645, + 0.42000001668930054, + 0.6299999952316284, + 1 + ] + }, + { + "implementation": "editor", + "source": "green", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "editor", + "source": "green", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "blue", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.020000001415610313, + 0.06000000238418579, + 0.08999999612569809, + 1 + ] + }, + { + "implementation": "editor", + "source": "blue", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.17999999225139618, + 0.5400000214576721, + 0.809999942779541, + 1 + ] + }, + { + "implementation": "editor", + "source": "blue", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "editor", + "source": "blue", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "editor", + "source": "luminance", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.07837200164794922, + 0.23511601984500885, + 0.3526740074157715, + 1 + ] + }, + { + "implementation": "editor", + "source": "luminance", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.12162800133228302, + 0.36488401889801025, + 0.5473259687423706, + 1 + ] + }, + { + "implementation": "editor", + "source": "luminance", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "editor", + "source": "luminance", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "normal", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "normal", + "opacity": 0.35, + "rgba": [ + 0.3050000071525574, + 0.5475000143051147, + 0.7599999308586121, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "normal", + "opacity": 1, + "rgba": [ + 0.5, + 0.45000001788139343, + 0.5, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "multiply", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "multiply", + "opacity": 0.35, + "rgba": [ + 0.19300000369548798, + 0.5264999866485596, + 0.7582499384880066, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "multiply", + "opacity": 1, + "rgba": [ + 0.18000000715255737, + 0.39000001549720764, + 0.4949999749660492, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "screen", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "screen", + "opacity": 0.35, + "rgba": [ + 0.31200000643730164, + 0.6209999918937683, + 0.9017499685287476, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "screen", + "opacity": 1, + "rgba": [ + 0.5200000405311584, + 0.6600000262260437, + 0.9049999713897705, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "overlay", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "overlay", + "opacity": 0.35, + "rgba": [ + 0.2840000092983246, + 0.5720000267028809, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "overlay", + "opacity": 1, + "rgba": [ + 0.4399999976158142, + 0.5200000405311584, + 0.85999995470047, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "darken", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "darken", + "opacity": 0.35, + "rgba": [ + 0.20000000298023224, + 0.5475000143051147, + 0.7599999308586121, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "darken", + "opacity": 1, + "rgba": [ + 0.20000000298023224, + 0.45000001788139343, + 0.5, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "lighten", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "lighten", + "opacity": 0.35, + "rgba": [ + 0.3050000071525574, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "lighten", + "opacity": 1, + "rgba": [ + 0.5, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-dodge", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-dodge", + "opacity": 0.35, + "rgba": [ + 0.3400000035762787, + 0.6449999809265137, + 0.9174999594688416, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-dodge", + "opacity": 1, + "rgba": [ + 0.6000000238418579, + 0.7285714745521545, + 0.949999988079071, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-burn", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-burn", + "opacity": 0.35, + "rgba": [ + 0.16500000655651093, + 0.4950000047683716, + 0.7424999475479126, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color-burn", + "opacity": 1, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hard-light", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hard-light", + "opacity": 0.35, + "rgba": [ + 0.2840000092983246, + 0.5720000267028809, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hard-light", + "opacity": 1, + "rgba": [ + 0.4399999976158142, + 0.5200000405311584, + 0.85999995470047, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "soft-light", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "soft-light", + "opacity": 0.35, + "rgba": [ + 0.22595743834972382, + 0.5877782702445984, + 0.8931843042373657, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "soft-light", + "opacity": 1, + "rgba": [ + 0.274164080619812, + 0.5650807023048401, + 0.8805266618728638, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "difference", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "difference", + "opacity": 0.35, + "rgba": [ + 0.27000001072883606, + 0.5475000143051147, + 0.8824999332427979, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "difference", + "opacity": 1, + "rgba": [ + 0.4000000059604645, + 0.45000001788139343, + 0.8499999642372131, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "exclusion", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "exclusion", + "opacity": 0.35, + "rgba": [ + 0.2839999794960022, + 0.5895000100135803, + 0.8859999179840088, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "exclusion", + "opacity": 1, + "rgba": [ + 0.4399999678134918, + 0.5700000524520874, + 0.8600000143051147, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hue", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hue", + "opacity": 0.35, + "rgba": [ + 0.33033299446105957, + 0.5728330016136169, + 0.785332977771759, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "hue", + "opacity": 1, + "rgba": [ + 0.572380006313324, + 0.522379994392395, + 0.572380006313324, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "saturation", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "saturation", + "opacity": 0.35, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "saturation", + "opacity": 1, + "rgba": [ + 0.19999998807907104, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color", + "opacity": 0.35, + "rgba": [ + 0.33033299446105957, + 0.5728330016136169, + 0.785332977771759, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "color", + "opacity": 1, + "rgba": [ + 0.572380006313324, + 0.522379994392395, + 0.572380006313324, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "luminosity", + "opacity": 0, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "luminosity", + "opacity": 0.35, + "rgba": [ + 0.17466700077056885, + 0.5746670365333557, + 0.8746669888496399, + 1 + ] + }, + { + "implementation": "runtime", + "mode": "luminosity", + "opacity": 1, + "rgba": [ + 0.12761999666690826, + 0.5276200175285339, + 0.8276199698448181, + 1 + ] + }, + { + "implementation": "runtime", + "source": "alpha", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "runtime", + "source": "alpha", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.10000000149011612, + 0.30000001192092896, + 0.44999998807907104, + 1 + ] + }, + { + "implementation": "runtime", + "source": "alpha", + "mode": "stencil", + "invert": false, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "alpha", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "red", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.1600000113248825, + 0.48000001907348633, + 0.7199999690055847, + 1 + ] + }, + { + "implementation": "runtime", + "source": "red", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.03999999910593033, + 0.11999999731779099, + 0.17999997735023499, + 1 + ] + }, + { + "implementation": "runtime", + "source": "red", + "mode": "stencil", + "invert": false, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "red", + "mode": "stencil", + "invert": true, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "runtime", + "source": "green", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.06000000238418579, + 0.18000000715255737, + 0.27000001072883606, + 1 + ] + }, + { + "implementation": "runtime", + "source": "green", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.14000000059604645, + 0.42000001668930054, + 0.6299999952316284, + 1 + ] + }, + { + "implementation": "runtime", + "source": "green", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "runtime", + "source": "green", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "blue", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.020000001415610313, + 0.06000000238418579, + 0.08999999612569809, + 1 + ] + }, + { + "implementation": "runtime", + "source": "blue", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.17999999225139618, + 0.5400000214576721, + 0.809999942779541, + 1 + ] + }, + { + "implementation": "runtime", + "source": "blue", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "runtime", + "source": "blue", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + }, + { + "implementation": "runtime", + "source": "luminance", + "mode": "multiply", + "invert": false, + "rgba": [ + 0.07837200164794922, + 0.23511601984500885, + 0.3526740074157715, + 1 + ] + }, + { + "implementation": "runtime", + "source": "luminance", + "mode": "multiply", + "invert": true, + "rgba": [ + 0.12162800133228302, + 0.36488401889801025, + 0.5473259687423706, + 1 + ] + }, + { + "implementation": "runtime", + "source": "luminance", + "mode": "stencil", + "invert": false, + "rgba": [ + 0, + 0, + 0, + 1 + ] + }, + { + "implementation": "runtime", + "source": "luminance", + "mode": "stencil", + "invert": true, + "rgba": [ + 0.20000000298023224, + 0.6000000238418579, + 0.8999999761581421, + 1 + ] + } +] diff --git a/tests/composition/baselines/legacy-text-mask.png b/tests/composition/baselines/legacy-text-mask.png new file mode 100644 index 00000000..bb1c40ec Binary files /dev/null and b/tests/composition/baselines/legacy-text-mask.png differ diff --git a/tests/composition/baselines/solid-text-background.png b/tests/composition/baselines/solid-text-background.png new file mode 100644 index 00000000..8645a06b Binary files /dev/null and b/tests/composition/baselines/solid-text-background.png differ diff --git a/tests/composition/baselines/transparent-text-screen.png b/tests/composition/baselines/transparent-text-screen.png new file mode 100644 index 00000000..bd1d17c0 Binary files /dev/null and b/tests/composition/baselines/transparent-text-screen.png differ diff --git a/tests/composition/browser.mjs b/tests/composition/browser.mjs new file mode 100644 index 00000000..28090653 --- /dev/null +++ b/tests/composition/browser.mjs @@ -0,0 +1,384 @@ +import { buildBlendNode as buildRuntimeBlendNode } from "@runtime/renderer/blend-modes" +import { float, vec4 } from "three/tsl" +import * as THREE from "three/webgpu" +import { + applyLabProjectFile, + buildLabProjectFile, + buildViewerProjectState, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildBlendNode } from "@/renderer/blend-modes" +import { buildRendererFrame } from "@/renderer/contracts" +import { createWebGPURenderer } from "@/renderer/create-webgpu-renderer" +import { useAssetStore } from "@/store/asset-store" +import { useEditorStore } from "@/store/editor-store" +import { useLayerStore } from "@/store/layer-store" +import { useTimelineStore } from "@/store/timeline-store" +import { BLEND_MODES } from "@/types/editor" +import { checkAlphaCompositing } from "./alpha-compositing.mjs" +import { checkGroups } from "./groups.mjs" +import { checkMediaBounds } from "./media-bounds.mjs" + +function pixels(canvas) { + const copy = document.createElement("canvas") + copy.width = canvas.width + copy.height = canvas.height + const context = copy.getContext("2d", { willReadFrequently: true }) + context.drawImage(canvas, 0, 0) + return context.getImageData(0, 0, copy.width, copy.height) +} + +function canonicalJson(value) { + return JSON.stringify(value, (_key, entry) => { + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + return Object.fromEntries( + Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)) + ) + } + return entry + }) +} + +function compare(actual, expected, tolerance = 0) { + if (actual.width !== expected.width || actual.height !== expected.height) { + throw new Error("Image dimensions changed") + } + let changedPixels = 0 + let maxDelta = 0 + for (let index = 0; index < actual.data.length; index += 4) { + let delta = 0 + for (let channel = 0; channel < 4; channel++) { + delta = Math.max( + delta, + Math.abs(actual.data[index + channel] - expected.data[index + channel]) + ) + } + if (delta > tolerance) changedPixels++ + maxDelta = Math.max(maxDelta, delta) + } + return { changedPixels, maxDelta } +} + +async function settle(renderer, frame) { + renderer.render(frame) + const deadline = performance.now() + 30_000 + while (renderer.hasPendingResources()) { + if (performance.now() > deadline) + throw new Error( + `Timed out loading or compiling a fixture (compilation pending: ${renderer.hasPendingCompilations()})` + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + } + await renderer.prepareForExportFrame(frame.clock.timelineTime, true) + renderer.render(frame) + await renderer.waitForGpuIdle() +} + +async function renderProject(value) { + const project = parseLabProjectFileValue(value) + const state = buildViewerProjectState(project) + const size = project.composition + const canvas = document.createElement("canvas") + const renderer = await createWebGPURenderer(canvas, { + strictPassFailures: true, + }) + try { + await renderer.initialize() + renderer.resize(size, 1) + const frame = buildRendererFrame({ + ...state, + outputSize: size, + viewportSize: size, + delta: 0, + clockTime: 0, + pixelRatio: 1, + timeline: { ...state.timeline, currentTime: 0, isPlaying: false }, + }) + await settle(renderer, frame) + const preview = pixels(canvas) + const exported = renderer.exportFrame(frame, size) + const exportDiff = compare(preview, pixels(exported)) + if (exportDiff.changedPixels) + throw new Error(`Preview/export mismatch: ${JSON.stringify(exportDiff)}`) + return { image: preview, png: exported.toDataURL("image/png") } + } finally { + renderer.dispose() + await renderer.destroyDevice() + } +} + +window.checkProject = async (name, update) => { + const file = await (await fetch(`/fixtures/${name}.json`)).json() + const first = await renderProject(file) + // Reopen a serialized project in a new renderer/device, with fresh textures. + const reopened = await renderProject( + JSON.parse(JSON.stringify(parseLabProjectFileValue(file))) + ) + const reopenDiff = compare(first.image, reopened.image) + if (reopenDiff.changedPixels) + throw new Error(`Reopen mismatch: ${JSON.stringify(reopenDiff)}`) + const colors = new Set() + for (let i = 0; i < first.image.data.length; i += 4) { + colors.add( + `${first.image.data[i]},${first.image.data[i + 1]},${first.image.data[i + 2]}` + ) + if (first.image.data[i + 3] !== 255) + throw new Error("Legacy fixture unexpectedly became transparent") + } + if (colors.size < 8) + throw new Error("Fixture is blank or has lost its content") + let baselineDiff = null + if (!update) { + const response = await fetch(`/baselines/${name}.png`) + if (!response.ok) throw new Error(`Missing baseline: ${name}`) + const bitmap = await createImageBitmap(await response.blob()) + const reference = document.createElement("canvas") + reference.width = bitmap.width + reference.height = bitmap.height + reference.getContext("2d").drawImage(bitmap, 0, 0) + bitmap.close() + baselineDiff = compare(first.image, pixels(reference), 2) + } + return { png: first.png, baselineDiff, reopenDiff, colors: colors.size } +} + +window.checkLegacyBlendContract = async () => { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const target = new THREE.RenderTarget(1, 1, { + type: THREE.FloatType, + depthBuffer: false, + }) + const geometry = new THREE.PlaneGeometry(2, 2) + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + const material = new THREE.MeshBasicNodeMaterial({ + transparent: true, + blending: THREE.NoBlending, + }) + const mesh = new THREE.Mesh(geometry, material) + const scene = new THREE.Scene() + scene.add(mesh) + const results = [] + try { + for (const [implementation, build] of [ + ["editor", buildBlendNode], + ["runtime", buildRuntimeBlendNode], + ]) { + for (const mode of BLEND_MODES) { + for (const opacity of [0, 0.35, 1]) { + material.colorNode = build( + mode, + vec4(0.2, 0.6, 0.9, 1), + vec4(0.8, 0.3, 0.1, 0.5), + float(opacity) + ) + material.needsUpdate = true + renderer.setRenderTarget(target) + renderer.render(scene, camera) + const data = await renderer.readRenderTargetPixelsAsync( + target, + 0, + 0, + 1, + 1 + ) + results.push({ + implementation, + mode, + opacity, + rgba: Array.from(data), + }) + } + } + for (const source of ["alpha", "red", "green", "blue", "luminance"]) { + for (const mode of ["multiply", "stencil"]) { + for (const invert of [false, true]) { + material.colorNode = build( + "normal", + vec4(0.2, 0.6, 0.9, 1), + vec4(0.8, 0.3, 0.1, 0.5), + float(1), + "mask", + { source, mode, invert } + ) + material.needsUpdate = true + renderer.render(scene, camera) + const data = await renderer.readRenderTargetPixelsAsync( + target, + 0, + 0, + 1, + 1 + ) + results.push({ + implementation, + source, + mode, + invert, + rgba: Array.from(data), + }) + } + } + } + } + return results + } finally { + target.dispose() + material.dispose() + geometry.dispose() + renderer.dispose() + renderer.backend.device.destroy() + } +} + +window.checkExistingProject = async () => { + const original = await ( + await fetch("/fixtures/existing-default-project.json") + ).json() + const parsed = parseLabProjectFileValue(original) + const reopened = parseLabProjectFileValue(JSON.parse(JSON.stringify(parsed))) + if (JSON.stringify(parsed) !== JSON.stringify(reopened)) + throw new Error("Saved project is not stable across parse/serialize/reopen") + const state = buildViewerProjectState(reopened) + const expectedRestored = structuredClone(reopened) + for (const layer of expectedRestored.layers) { + if (layer.type === "image" || layer.type === "video") { + layer.params.transparentBounds = false + } + } + // Start from a different editor session so no-op restoration cannot pass + // merely because the stores already contain their default values. + useLayerStore.getState().replaceState([], null, null) + useAssetStore.getState().replaceAssets([]) + useEditorStore.getState().updateSceneConfig({ exposure: 2 }) + useEditorStore.getState().setOutputSize(320, 240) + useTimelineStore.setState({ currentTime: 3, isPlaying: false, loop: false }) + const revision = useEditorStore.getState().sceneRevision + const missing = applyLabProjectFile(reopened, []) + if (missing.missingAssetCount || missing.missingAudioSource) { + throw new Error("Editor hydration lost bundled media") + } + if (useEditorStore.getState().sceneRevision !== revision + 1) { + throw new Error("Editor did not signal scene replacement") + } + const stored = buildLabProjectFile() + for (const key of [ + "layers", + "sceneConfig", + "composition", + "selectedLayerId", + "timeline", + "audio", + ]) { + if (JSON.stringify(stored[key]) !== JSON.stringify(expectedRestored[key])) { + throw new Error(`Editor hydration/save changed ${key}`) + } + } + if ( + useAssetStore.getState().assets.length !== original.assets.length || + useLayerStore.getState().layers.length !== original.layers.length || + useTimelineStore.getState().currentTime !== 0 + ) { + throw new Error("Editor stores were not restored") + } + if ( + state.layers.length !== original.layers.length || + state.assets.length !== original.assets.length + ) { + throw new Error("Existing project lost layers or bundled assets") + } + for (const [index, layer] of state.layers.entries()) { + const saved = expectedRestored.layers[index] + for (const key of [ + "blendMode", + "compositeMode", + "maskConfig", + "opacity", + "params", + ]) { + if (JSON.stringify(layer[key]) !== JSON.stringify(saved[key])) + throw new Error(`Existing ${layer.type} layer changed ${key}`) + } + } + const validTrack = { + id: "hydration-opacity", + layerId: reopened.layers[0].id, + binding: { + kind: "layer", + property: "opacity", + label: "Opacity", + valueType: "number", + }, + enabled: true, + keyframes: [ + { + id: "opacity-start", + time: 0, + value: 0.25, + easing: { type: "bezier", controlPoints: [0, 0, 1, 1] }, + }, + { + id: "opacity-end", + time: 1, + value: 1, + easing: { type: "bezier", controlPoints: [0, 0, 1, 1] }, + }, + ], + } + const tracks = [ + validTrack, + { ...validTrack, id: "missing-layer", layerId: "deleted-layer" }, + { + ...validTrack, + id: "missing-parameter", + binding: { + kind: "param", + key: "removed-parameter", + label: "Removed", + valueType: "number", + }, + }, + ] + const withTracks = structuredClone(reopened) + withTracks.timeline.tracks = tracks + withTracks.selectedLayerId = "deleted-layer" + useTimelineStore.setState({ + tracks: [{ ...validTrack, id: "previous-session-track" }], + currentTime: 3, + isPlaying: false, + selectedTrackId: "previous-session-track", + selectedKeyframeId: "previous-session-keyframe", + selectedKeyframeIds: ["previous-session-keyframe"], + }) + applyLabProjectFile(withTracks, useAssetStore.getState().assets) + const timeline = useTimelineStore.getState() + if (canonicalJson(timeline.tracks) !== canonicalJson([validTrack])) { + throw new Error( + `Hydration must retain valid tracks and prune missing layers/parameters: ${JSON.stringify(timeline.tracks)}` + ) + } + if ( + timeline.currentTime !== 0 || + !timeline.isPlaying || + timeline.selectedTrackId !== null || + timeline.selectedKeyframeId !== null || + timeline.selectedKeyframeIds.length !== 0 || + useLayerStore.getState().selectedLayerId !== null + ) { + throw new Error( + "Hydration retained stale timeline state or an invalid layer selection" + ) + } + if ( + canonicalJson(buildLabProjectFile().timeline.tracks) !== + canonicalJson([validTrack]) + ) { + throw new Error("Saving restored pruned timeline tracks") + } + return { layers: state.layers.length, assets: state.assets.length } +} + +window.checkMediaBounds = () => checkMediaBounds(renderProject) +window.checkAlphaCompositing = checkAlphaCompositing +window.checkGroups = checkGroups diff --git a/tests/composition/fixtures/alpha-sample.svg b/tests/composition/fixtures/alpha-sample.svg new file mode 100644 index 00000000..7be9db5a --- /dev/null +++ b/tests/composition/fixtures/alpha-sample.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/composition/fixtures/contained-image-halftone.json b/tests/composition/fixtures/contained-image-halftone.json new file mode 100644 index 00000000..5b66ba46 --- /dev/null +++ b/tests/composition/fixtures/contained-image-halftone.json @@ -0,0 +1,204 @@ +{ + "format": "shader-lab", + "version": 6, + "composition": { + "width": 256, + "height": 192 + }, + "assets": [ + { + "id": "sample", + "kind": "image", + "fileName": "alpha-sample.svg", + "url": "/scenes/default/alpha-sample.svg", + "mimeType": "image/svg+xml", + "width": 160, + "height": 160, + "duration": null, + "sizeBytes": 570 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "halftone", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Halftone", + "opacity": 0.65, + "params": { + "colorMode": "cmyk", + "shape": "circle", + "spacing": 5, + "dotSize": 1, + "dotMin": 0, + "angle": 28, + "contrast": 1, + "softness": 0.25, + "dotGain": 0, + "dotMorph": 0, + "invertLuma": false, + "customColorCount": 4, + "customLuminanceBias": 0, + "bloomEnabled": false, + "bloomIntensity": 1.25, + "bloomThreshold": 0.6, + "bloomRadius": 6, + "bloomSoftness": 0.35, + "customBgColor": "#F5F5F0", + "customColor1": "#0d1014", + "customColor2": "#4d5057", + "customColor3": "#969aa2", + "customColor4": "#e1e2de", + "ink": "#0d1014", + "duotoneLight": "#F5F5F0", + "duotoneDark": "#1d1d1c", + "preset": "process", + "gcr": 0.5, + "cmykBlend": "subtractive", + "cyanAngle": 15, + "magentaAngle": 75, + "yellowAngle": 0, + "keyAngle": 45, + "paperColor": "#F5F5F0", + "paperGrain": 0.15, + "registration": 0, + "inkCyan": "#00AEEF", + "inkMagenta": "#EC008C", + "inkYellow": "#FFF200", + "inkKey": "#1a1a1a" + }, + "runtimeError": null, + "saturation": 1, + "type": "halftone", + "visible": true + }, + { + "assetId": "sample", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "image", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Image", + "opacity": 1, + "params": { + "fitMode": "contain", + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + } + ], + "selectedLayerId": "halftone", + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + } +} diff --git a/tests/composition/fixtures/existing-default-project.json b/tests/composition/fixtures/existing-default-project.json new file mode 100644 index 00000000..33dfad0a --- /dev/null +++ b/tests/composition/fixtures/existing-default-project.json @@ -0,0 +1,473 @@ +{ + "assets": [ + { + "duration": 5.041667, + "fileName": "aura.mp4", + "height": 720, + "id": "efb4eebe-d991-44c8-8f08-8fac18281a9b", + "kind": "video", + "mimeType": "video/mp4", + "sizeBytes": 629868, + "url": "/scenes/default/aura.mp4", + "width": 578 + }, + { + "duration": 59.996, + "fileName": "aura.ogg", + "height": null, + "id": "f4a5f106-8d90-46a2-b86f-b68a7a1cd5fb", + "kind": "audio", + "mimeType": "audio/ogg", + "sizeBytes": 779577, + "url": "/scenes/default/aura.ogg", + "width": null + } + ], + "audio": { + "bands": { + "bass": { + "attackMs": 219, + "gainDb": 0, + "highHz": 140, + "lowHz": 20, + "releaseMs": 560 + }, + "mid": { + "attackMs": 246, + "gainDb": 0, + "highHz": 2000, + "lowHz": 140, + "releaseMs": 493 + }, + "high": { + "attackMs": 202, + "gainDb": 0, + "highHz": 16000, + "lowHz": 2000, + "releaseMs": 424 + }, + "level": { + "attackMs": 264, + "gainDb": 0, + "highHz": 20000, + "lowHz": 20, + "releaseMs": 577 + } + }, + "links": [], + "offsetSeconds": 0, + "source": { + "assetId": "f4a5f106-8d90-46a2-b86f-b68a7a1cd5fb", + "kind": "asset" + } + }, + "composition": { + "height": 949, + "width": 1512 + }, + "exportedAt": "2026-08-14T15:16:56.006Z", + "format": "shader-lab", + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 4, + "id": "eb6dda3c-1632-44c5-9a29-d4f382a09b8d", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Displacement Map", + "opacity": 1, + "params": { + "channel": "luminance", + "direction": "both", + "midpoint": 0.23, + "strength": 82 + }, + "runtimeError": null, + "saturation": 2, + "type": "displacement-map", + "visible": true + }, + { + "assetId": null, + "blendMode": "screen", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "83cd9b9f-7584-4779-96e1-c387bfbf55e6", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Bloom", + "opacity": 1, + "params": { + "bloomIntensity": 1.66, + "bloomKnee": 0.22, + "bloomRadius": 13.25, + "bloomSoftness": 0.92, + "bloomThreshold": 0.29, + "highlightDrive": 1.95 + }, + "runtimeError": null, + "saturation": 1, + "type": "bloom", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "a349a6a1-31db-45bc-bc48-f11a696eb206", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Progressive Blur", + "opacity": 1, + "params": { + "angle": 0, + "end": 0.73, + "samples": 9, + "start": 0.59, + "strength": 20 + }, + "runtimeError": null, + "saturation": 1, + "type": "smear", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 2, + "id": "40c39149-a3e6-4abc-a5c2-f0f4c7e2e67b", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "ASCII", + "opacity": 1, + "params": { + "bgOpacity": 0.18, + "boldness": 0.26, + "breakGrid": "off", + "breakThreshold": 0.06, + "charset": "light", + "colorMode": "source", + "columns": 358, + "customChars": " .:-=+*#%@", + "fontFamily": "mono", + "fontWeight": 400, + "invert": false, + "monoColor": "#f5f5f0", + "rowWarp": 0, + "signalBlackPoint": 0, + "signalWhitePoint": 1 + }, + "runtimeError": null, + "saturation": 1.35, + "type": "ascii", + "visible": true + }, + { + "assetId": null, + "blendMode": "lighten", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "d6b5aa25-7379-4ce9-9b1b-048aff0a0f81", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Pixel Trail", + "opacity": 1, + "params": { + "cellSize": 24, + "decay": 0.862, + "displaceAmount": 0.183, + "intensity": 1.9, + "radius": 0.07 + }, + "runtimeError": null, + "saturation": 1, + "type": "pixel-trail", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "89ce5095-45f8-46c7-86cb-bc6bb3ad7fff", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Blob Tracking", + "opacity": 1, + "params": { + "blobAmount": 4, + "centerShape": "cross", + "connectLines": true, + "connectorArrows": false, + "connectorDashed": false, + "curvedLines": false, + "detectionMode": "luminance", + "innerEffectParams": "", + "innerEffectType": "none", + "invert": false, + "minBlobSize": 6, + "motionMaskThreshold": 0.08, + "motionPersistence": 0.82, + "motionThreshold": 0.36, + "outputMode": "decorated", + "persistentTracking": true, + "sensitivity": 0.72, + "shapeScale": 1, + "shapeType": "square", + "showLabels": true, + "showOutline": true, + "smoothing": 0.52, + "strokeColor": "#E6E6E6", + "strokeWidth": 2, + "trailDecay": 0.35 + }, + "runtimeError": null, + "saturation": 1, + "type": "blob-tracking", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "6bfa2084-cf57-4f8a-bb49-57cfa1c74b1c", + "kind": "effect", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Dithering", + "opacity": 1, + "params": { + "algorithm": "bayer-4x4", + "animateDither": false, + "chromaticSplit": false, + "colorMode": "source", + "ditherSpeed": 1, + "dotScale": 1, + "highlightColor": "#f5f2e8", + "levels": 3, + "monoColor": "#f5f5f0", + "pixelSize": 1, + "preset": "custom", + "shadowColor": "#101010", + "spread": 0.5 + }, + "runtimeError": null, + "saturation": 1, + "type": "dithering", + "visible": true + }, + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "mask", + "expanded": true, + "hue": 0, + "id": "0e3136ed-a783-4527-888d-269caeb49a9f", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Text", + "opacity": 1, + "params": { + "anchor": "center", + "backgroundAlpha": 1, + "backgroundColor": "#000000", + "fontFamily": "sans", + "fontSize": 201, + "fontWeight": 800, + "letterSpacing": -0.1, + "offset": [ + 0, + 0 + ], + "text": "ShaderLab", + "textColor": "#ffffff" + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "efb4eebe-d991-44c8-8f08-8fac18281a9b", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "cd60ee67-6fbe-41f2-9281-547a12823cc1", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Video", + "opacity": 1, + "params": { + "fitMode": "cover", + "offset": [ + 0, + 0 + ], + "playbackRate": 1, + "scale": 1.23 + }, + "runtimeError": null, + "saturation": 1, + "type": "video", + "visible": true + } + ], + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 2.42, + "brightness": 0, + "contrast": 0.05, + "saturation": 1.12, + "vibrance": 0.1, + "hue": 9, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0.01213968211206895 + }, + { + "x": 0.2680158943965517, + "y": 0.23698477909482762 + }, + { + "x": 0.4523504849137931, + "y": 0.5597218480603448 + }, + { + "x": 0.7020979256465517, + "y": 0.798760775862069 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "selectedLayerId": "cd60ee67-6fbe-41f2-9281-547a12823cc1", + "timeline": { + "duration": 59.996, + "loop": true, + "tracks": [] + }, + "version": 6 +} diff --git a/tests/composition/fixtures/legacy-text-mask.json b/tests/composition/fixtures/legacy-text-mask.json new file mode 100644 index 00000000..d4134be9 --- /dev/null +++ b/tests/composition/fixtures/legacy-text-mask.json @@ -0,0 +1,177 @@ +{ + "format": "shader-lab", + "version": 6, + "composition": { + "width": 256, + "height": 192 + }, + "assets": [ + { + "id": "sample", + "kind": "image", + "fileName": "alpha-sample.svg", + "url": "/scenes/default/alpha-sample.svg", + "mimeType": "image/svg+xml", + "width": 160, + "height": 160, + "duration": null, + "sizeBytes": 570 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "mask", + "expanded": true, + "hue": 0, + "id": "text", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Text", + "opacity": 1, + "params": { + "text": "V3", + "anchor": "center", + "offset": [ + 0, + 0 + ], + "fontSize": 96, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": 0, + "textColor": "#ffffff", + "backgroundColor": "#000000", + "backgroundAlpha": 1 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "sample", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "image", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Image", + "opacity": 1, + "params": { + "fitMode": "contain", + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + } + ], + "selectedLayerId": "text", + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + } +} diff --git a/tests/composition/fixtures/solid-text-background.json b/tests/composition/fixtures/solid-text-background.json new file mode 100644 index 00000000..2cf18469 --- /dev/null +++ b/tests/composition/fixtures/solid-text-background.json @@ -0,0 +1,177 @@ +{ + "format": "shader-lab", + "version": 6, + "composition": { + "width": 256, + "height": 192 + }, + "assets": [ + { + "id": "sample", + "kind": "image", + "fileName": "alpha-sample.svg", + "url": "/scenes/default/alpha-sample.svg", + "mimeType": "image/svg+xml", + "width": 160, + "height": 160, + "duration": null, + "sizeBytes": 570 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "text", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Text", + "opacity": 0.75, + "params": { + "text": "V3", + "anchor": "center", + "offset": [ + 0, + 0 + ], + "fontSize": 96, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": 0, + "textColor": "#ffffff", + "backgroundColor": "#000000", + "backgroundAlpha": 1 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "sample", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "image", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Image", + "opacity": 1, + "params": { + "fitMode": "contain", + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + } + ], + "selectedLayerId": "text", + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + } +} diff --git a/tests/composition/fixtures/transparent-text-screen.json b/tests/composition/fixtures/transparent-text-screen.json new file mode 100644 index 00000000..96d0c2de --- /dev/null +++ b/tests/composition/fixtures/transparent-text-screen.json @@ -0,0 +1,177 @@ +{ + "format": "shader-lab", + "version": 6, + "composition": { + "width": 256, + "height": 192 + }, + "assets": [ + { + "id": "sample", + "kind": "image", + "fileName": "alpha-sample.svg", + "url": "/scenes/default/alpha-sample.svg", + "mimeType": "image/svg+xml", + "width": 160, + "height": 160, + "duration": null, + "sizeBytes": 570 + } + ], + "layers": [ + { + "assetId": null, + "blendMode": "screen", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "text", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Text", + "opacity": 1, + "params": { + "text": "V3", + "anchor": "center", + "offset": [ + 0, + 0 + ], + "fontSize": 96, + "fontFamily": "mono", + "fontWeight": 400, + "letterSpacing": 0, + "textColor": "#ff713d", + "backgroundColor": "#000000", + "backgroundAlpha": 0 + }, + "runtimeError": null, + "saturation": 1, + "type": "text", + "visible": true + }, + { + "assetId": "sample", + "blendMode": "normal", + "compositeMode": "filter", + "expanded": true, + "hue": 0, + "id": "image", + "kind": "source", + "locked": false, + "maskConfig": { + "invert": false, + "mode": "multiply", + "source": "luminance" + }, + "name": "Image", + "opacity": 1, + "params": { + "fitMode": "contain", + "scale": 1, + "offset": [ + 0, + 0 + ], + "svgRasterResolution": "2048" + }, + "runtimeError": null, + "saturation": 1, + "type": "image", + "visible": true + } + ], + "selectedLayerId": "text", + "sceneConfig": { + "backgroundColor": "#080808", + "compositionAspect": "screen", + "compositionWidth": 1920, + "compositionHeight": 1080, + "exposure": 0, + "brightness": 0, + "contrast": 0, + "saturation": 1, + "vibrance": 0, + "hue": 0, + "temperature": 0, + "tint": 0, + "invert": false, + "channelMixer": { + "rr": 1, + "rg": 0, + "rb": 0, + "gr": 0, + "gg": 1, + "gb": 0, + "br": 0, + "bg": 0, + "bb": 1 + }, + "colorCurves": { + "blue": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "green": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "red": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + }, + "rgb": { + "points": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ] + } + }, + "clampMin": 0, + "clampGamma": 1, + "clampMax": 1, + "quantizeEnabled": false, + "quantizeLevels": 256, + "colorMap": null + }, + "timeline": { + "duration": 5, + "loop": true, + "tracks": [] + } +} diff --git a/tests/composition/groups.mjs b/tests/composition/groups.mjs new file mode 100644 index 00000000..4c69d3fd --- /dev/null +++ b/tests/composition/groups.mjs @@ -0,0 +1,524 @@ +import { PipelineManager as RuntimePipeline } from "@runtime/renderer/pipeline-manager" +import { createHeadlessRenderer } from "@runtime/renderer/create-headless-renderer" +import { + flattenComposition as flattenRuntime, + reverseComposition as reverseRuntime, +} from "@runtime/renderer/composition-tree" +import { texture } from "three/tsl" +import * as THREE from "three/webgpu" +import { createLayer } from "@/lib/editor/layers" +import { + flattenComposition, + reverseComposition, +} from "@/renderer/composition-tree" +import { createWebGPURenderer } from "@/renderer/create-webgpu-renderer" +import { PipelineManager as EditorPipeline } from "@/renderer/pipeline-manager" +import { DEFAULT_SCENE_CONFIG } from "@/types/editor" + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function close(actual, expected, label, tolerance = 0.002) { + assert( + actual.length === expected.length && + actual.every( + (value, i) => + Number.isFinite(value) && Math.abs(value - expected[i]) <= tolerance + ), + `${label}: expected ${expected}, received ${actual}` + ) +} + +function solid(id, color, opacity = 1) { + const layer = createLayer("gradient") + return { + ...layer, + id, + opacity, + params: { + ...layer.params, + animate: false, + tonemapMode: "none", + grainAmount: 0, + glowStrength: 0, + vignetteStrength: 0, + ...Object.fromEntries( + [1, 2, 3, 4, 5].map((i) => [`point${i}Color`, color]) + ), + }, + } +} + +function group(id, children, options = {}) { + return { + id, + kind: "group", + children, + blendMode: "normal", + opacity: 1, + visible: true, + ...options, + } +} + +function editorNodes(nodes) { + return nodes.map((node) => + node.kind === "group" + ? { ...node, children: editorNodes(node.children) } + : { layer: node, params: node.params, asset: node.testAsset ?? null } + ) +} + +const red = solid("red", "#ff0000", 0.5) +const green = solid("green", "#00ff00", 0.5) +const blue = solid("blue", "#0000ff") +const effect = { + ...createLayer("threshold"), + id: "threshold", + params: { threshold: 0.1, softness: 0.01, noise: 0 }, +} + +// All arrays below use sidebar order (top first), including group children. +const cases = [ + ["isolated effect", [group("g", [effect, red]), blue], [0.5, 0.5, 1, 1]], + ["empty group", [group("g", []), blue], [0, 0, 1, 1]], + [ + "effect-only group cannot see the scene", + [group("g", [effect]), blue], + [0, 0, 1, 1], + ], + [ + "hidden group", + [group("g", [effect, red], { visible: false }), blue], + [0, 0, 1, 1], + ], + [ + "zero group opacity", + [group("g", [effect, red], { opacity: 0 }), blue], + [0, 0, 1, 1], + ], + [ + "opacity applies after children combine", + [group("g", [green, red], { opacity: 0.5 }), blue], + [0.125, 0.25, 0.625, 1], + ], + [ + "child order", + [group("g", [red, green], { opacity: 0.5 }), blue], + [0.25, 0.125, 0.625, 1], + ], + [ + "hidden child", + [group("g", [{ ...green, visible: false }, red]), blue], + [0.5, 0, 0.5, 1], + ], + [ + "nested group opacity", + [ + group("outer", [group("g", [red], { opacity: 0.5 })], { opacity: 0.5 }), + blue, + ], + [0.125, 0, 0.875, 1], + ], + [ + "sibling effects stay isolated", + [group("g2", [green]), group("g", [effect, red]), blue], + [0.25, 0.75, 0.5, 1], + ], + [ + "external top layer stays unaffected", + [green, group("g", [effect, red]), blue], + [0.25, 0.75, 0.5, 1], + ], + [ + "parent effect processes combined child", + [group("outer", [effect, group("g", [red])]), blue], + [0.5, 0.5, 1, 1], + ], + [ + "group blend mode", + [group("g", [red], { blendMode: "multiply" }), blue], + [0, 0, 0.5, 1], + ], + [ + "group reorder below backdrop", + [blue, group("g", [effect, red])], + [0, 0, 1, 1], + ], + [ + "move effect outside group", + [effect, group("g", [red]), blue], + [1, 1, 1, 1], + ], + ["ungroup", [green, red, blue], [0.25, 0.5, 0.25, 1]], +] + +export async function checkGroups() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + renderer.setSize(4, 4, false) + renderer.setClearColor(0x123456, 0.37) + const compilations = [] + const compileAsync = renderer.compileAsync.bind(renderer) + renderer.compileAsync = (...args) => { + const promise = compileAsync(...args) + compilations.push(promise) + return promise + } + const settle = async () => { + while (compilations.length) await Promise.all(compilations.splice(0)) + } + const output = new THREE.RenderTarget(4, 4, { + type: THREE.FloatType, + depthBuffer: false, + }) + const material = new THREE.MeshBasicNodeMaterial({ + blending: THREE.NoBlending, + }) + const geometry = new THREE.PlaneGeometry(2, 2) + const scene = new THREE.Scene() + scene.add(new THREE.Mesh(geometry, material)) + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + const read = async (input) => { + material.colorNode = texture(input) + material.needsUpdate = true + renderer.setRenderTarget(output) + renderer.render(scene, camera) + // Read rows separately: this Three backend includes GPU row padding in + // multi-row readbacks whose width is not aligned to 256 bytes. + const rows = await Promise.all( + Array.from({ length: output.height }, async (_, y) => + Array.from( + await renderer.readRenderTargetPixelsAsync( + output, + 0, + y, + output.width, + 1 + ) + ) + ) + ) + return rows.flat() + } + let samples = 0 + try { + for (const [name, Pipeline, reverse, flatten] of [ + ["editor", EditorPipeline, reverseComposition, flattenComposition], + ["runtime", RuntimePipeline, reverseRuntime, flattenRuntime], + ]) { + const pipeline = new Pipeline(renderer, { width: 4, height: 4 }) + const nodesFor = (nodes) => + name === "editor" ? editorNodes(nodes) : nodes + const paint = async (nodes) => { + pipeline.syncLayers(reverse(nodesFor(nodes))) + await settle() + let result + if (name === "runtime") result = pipeline.renderToTexture(0, 0) + else { + pipeline.render(0, 0, 2) + // Inspect the same texture the editor blits to its canvas. + result = pipeline.blitInputNode.value + } + return read(result) + } + try { + for (const [label, nodes, expected] of cases) { + const actual = await paint(nodes) + for (let i = 0; i < actual.length; i += 4) + close(actual.slice(i, i + 4), expected, `${name}: ${label}`) + samples++ + } + const restoredColor = renderer.getClearColor(new THREE.Color()) + assert( + restoredColor.getHex() === 0x123456 && + renderer.getClearAlpha() === 0.37, + `${name}: group changed shared renderer clear settings` + ) + samples++ + + // Reparent existing passes, rather than restarting their media or simulation. + await paint([red, blue]) + const redPass = pipeline.passMap.get("red") + await paint([group("g", [red]), blue]) + assert( + pipeline.passMap.get("red") === redPass, + `${name}: reparent recreated child pass` + ) + const groupPass = pipeline.passMap.get("g") + const disposed = new Set() + for (const [label, resource] of [ + ["a", groupPass.rtA], + ["b", groupPass.rtB], + ["material", groupPass.material], + ]) { + resource.addEventListener("dispose", () => disposed.add(label)) + } + + pipeline.resize({ width: 8, height: 6 }) + output.setSize(8, 6) + close( + (await paint([group("g", [red]), blue])).slice(0, 4), + [0.5, 0, 0.5, 1], + `${name}: resized group` + ) + assert( + groupPass.rtA.width === 8 && groupPass.rtB.height === 6, + `${name}: group targets did not resize` + ) + disposed.clear() + await paint([red, blue]) + assert( + disposed.size === 3, + `${name}: removed group leaked GPU resources` + ) + assert( + pipeline.passMap.get("red") === redPass, + `${name}: ungroup disposed child` + ) + samples += 3 + + // Animation is discovered through nested groups, but hidden parents stop it. + await paint([group("g", [red]), blue]) + const child = pipeline.passMap.get("red") + child.needsContinuousRender = () => true + assert( + pipeline.passMap.get("g").needsContinuousRender(), + `${name}: animation did not propagate` + ) + if (name === "editor") { + const times = [] + child.prepareForExportFrame = async (...args) => { + times.push(args) + } + await pipeline.prepareForExportFrame(1.25, true) + assert( + times[0]?.[0] === 1.25 && times[0]?.[1] === true, + "Nested export preparation lost time/loop" + ) + await paint([group("g", [red], { visible: false }), blue]) + await pipeline.prepareForExportFrame(2, true) + assert(times.length === 1, "Hidden group prepared media for export") + } + await paint([group("g", [red], { visible: false }), blue]) + assert( + !pipeline.render(0, 0), + `${name}: hidden group kept animation running` + ) + samples++ + + const duplicates = [group("g", [red]), red] + let rejected = false + try { + pipeline.syncLayers(reverse(nodesFor(duplicates))) + } catch { + rejected = true + } + assert(rejected, `${name}: duplicate IDs were accepted`) + // Validation must happen before disposing any current passes. + assert( + pipeline.passMap.get("red") === child, + `${name}: invalid tree mutated live passes` + ) + let nested = red + for (let i = 0; i < 8; i++) nested = group(`depth-${i}`, [nested]) + flatten(nodesFor([nested]), (entry) => + name === "editor" ? entry.layer.id : entry.id + ) + rejected = false + try { + reverse(nodesFor([group("too-deep", [nested])])) + } catch { + rejected = true + } + assert(rejected, `${name}: excessive depth was accepted`) + samples += 2 + + if (name === "runtime") { + const input = new THREE.DataTexture( + new Float32Array([0, 0, 1, 0.5]), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + input.needsUpdate = true + try { + pipeline.syncLayers(reverse([group("g", [red])])) + await settle() + const pixel = ( + await read(pipeline.renderToTexture(0, 0, input)) + ).slice(0, 4) + close( + pixel, + [2 / 3, 0, 1 / 3, 0.75], + "Runtime group over transparent external input" + ) + samples++ + } finally { + input.dispose() + } + } + } finally { + pipeline.dispose() + output.setSize(4, 4) + } + } + + const headless = createHeadlessRenderer({ + renderer, + size: { width: 4, height: 4 }, + }) + try { + await headless.initialize() + const frame = { + clock: { time: 0, delta: 0, duration: 1, loop: true }, + layers: [ + group("outer", [group("inner", [green, red], { opacity: 0.5 })]), + blue, + ], + viewportSize: { width: 4, height: 4 }, + outputSize: { width: 4, height: 4 }, + logicalSize: { width: 4, height: 4 }, + pixelRatio: 1, + } + headless.render(frame) + await settle() + close( + (await read(headless.render(frame))).slice(0, 4), + [0.125, 0.25, 0.625, 1], + "Headless entry point reverses nested children" + ) + samples++ + } finally { + headless.dispose() + } + } finally { + output.dispose() + material.dispose() + geometry.dispose() + renderer.dispose() + } + + // The real editor entry point must preserve ordering at every depth and match + // its PNG export. The photograph fixture includes clear and soft-alpha regions. + const size = { width: 64, height: 48 } + const image = { ...createLayer("image"), id: "photo" } + image.params = { + ...image.params, + scale: 0.5, + transparentBounds: true, + fitMode: "contain", + } + image.testAsset = { + id: "fixture", + kind: "image", + url: "/scenes/default/alpha-sample.svg", + width: 160, + height: 160, + fileName: "alpha-sample.svg", + } + const canvas = document.createElement("canvas") + const editor = await createWebGPURenderer(canvas, { + strictPassFailures: true, + }) + let png + try { + await editor.initialize() + editor.resize(size, 1) + const frame = { + clock: { + time: 0, + timelineTime: 0, + delta: 0, + duration: 1, + loop: true, + isPlaying: false, + }, + cropAspectRatio: null, + logicalSize: size, + outputSize: size, + pixelRatio: 1, + viewportSize: size, + sceneConfig: DEFAULT_SCENE_CONFIG, + layers: editorNodes([ + group("outer", [group("portrait", [effect, image])]), + blue, + ]), + } + editor.render(frame) + const deadline = performance.now() + 30_000 + while (editor.hasPendingResources()) { + assert( + performance.now() < deadline, + "Grouped editor render did not settle" + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + } + await editor.prepareForExportFrame(0, true) + editor.render(frame) + await editor.waitForGpuIdle() + const snapshot = document.createElement("canvas") + snapshot.width = size.width + snapshot.height = size.height + const ctx = snapshot.getContext("2d", { willReadFrequently: true }) + ctx.drawImage(canvas, 0, 0) + const preview = ctx.getImageData(0, 0, size.width, size.height).data + close( + Array.from(preview.slice(0, 4)), + [0, 0, 255, 255], + "Group holes reveal blue background", + 1 + ) + const center = (24 * size.width + 32) * 4 + close( + Array.from(preview.slice(center, center + 4)), + [255, 255, 255, 255], + "Group effect modifies photograph", + 1 + ) + const exported = editor.exportFrame(frame, size) + ctx.clearRect(0, 0, size.width, size.height) + ctx.drawImage(exported, 0, 0) + close( + Array.from(ctx.getImageData(0, 0, size.width, size.height).data), + Array.from(preview), + "Grouped preview/PNG export", + 0 + ) + png = exported.toDataURL("image/png") + samples += 3 + + const snapshotFrame = async (nodes) => { + const nextFrame = { ...frame, layers: editorNodes(nodes) } + editor.render(nextFrame) + while (editor.hasPendingResources()) { + assert(performance.now() < deadline, "Regrouped image did not settle") + await new Promise((resolve) => setTimeout(resolve, 20)) + } + editor.render(nextFrame) + await editor.waitForGpuIdle() + ctx.clearRect(0, 0, size.width, size.height) + ctx.drawImage(canvas, 0, 0) + return Array.from(ctx.getImageData(0, 0, size.width, size.height).data) + } + const flatImage = await snapshotFrame([image, blue]) + const groupedImage = await snapshotFrame([ + group("outer", [group("portrait", [image])]), + blue, + ]) + close( + groupedImage, + flatImage, + "Nested group retains image orientation and soft alpha edges", + 1 + ) + samples++ + } finally { + editor.dispose() + await editor.destroyDevice() + } + return { samples, png } +} diff --git a/tests/composition/media-bounds.mjs b/tests/composition/media-bounds.mjs new file mode 100644 index 00000000..21bbab2b --- /dev/null +++ b/tests/composition/media-bounds.mjs @@ -0,0 +1,222 @@ +import { MediaPass as RuntimeMediaPass } from "@runtime/renderer/media-pass" +import * as THREE from "three/webgpu" +import { createLayer } from "@/lib/editor/layers" +import { + applyLabProjectFile, + buildLabProjectFile, + buildViewerProjectState, + migrateLayerParams, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildShaderExportConfig } from "@/lib/editor/shader-export" +import { MediaPass } from "@/renderer/media-pass" + +function assertPixel(actual, expected, label) { + if ( + actual.length !== expected.length || + actual.some( + (value, index) => + !Number.isFinite(value) || Math.abs(value - expected[index]) > 0.0001 + ) + ) { + throw new Error(`${label}: expected ${expected}, received ${actual}`) + } +} + +async function checkMediaPasses() { + const renderer = new THREE.WebGPURenderer({ antialias: false }) + await renderer.init() + renderer.toneMapping = THREE.NoToneMapping + const backdrop = [0.2, 0.4, 0.8, 1] + const input = new THREE.DataTexture( + new Float32Array(backdrop), + 1, + 1, + THREE.RGBAFormat, + THREE.FloatType + ) + input.needsUpdate = true + const target = new THREE.RenderTarget(64, 48, { + type: THREE.FloatType, + depthBuffer: false, + }) + let count = 0 + try { + for (const [name, Pass] of [ + ["editor", MediaPass], + ["runtime", RuntimeMediaPass], + ]) { + for (const kind of ["image", "video"]) { + const pass = new Pass(`${name}-${kind}`) + try { + pass.resize(64, 48) + const url = + kind === "image" + ? "/scenes/default/alpha-sample.svg" + : "/scenes/default/aura.mp4" + if (name === "editor") { + await pass.setMedia({ + url, + kind, + isSvg: kind === "image", + width: kind === "image" ? 160 : 578, + height: kind === "image" ? 160 : 720, + }) + pass.setPreviewFrozen(true) + await pass.prepareForExportFrame(0) + } else { + await pass.setMedia(url, kind) + } + const render = async (params, opacity = 1) => { + pass.updateParams(params) + pass.updateOpacity(opacity) + pass.render(renderer, input, target, 0, 0) + return Array.from( + await renderer.readRenderTargetPixelsAsync(target, 0, 24, 64, 1) + ) + } + for (const placement of [ + { scale: 1, offset: [0, 0] }, + { scale: 0.5, offset: [0.2, 0] }, + ]) { + const params = { ...placement, fitMode: "contain" } + const legacy = await render(params) + const solid = await render({ ...params, transparentBounds: false }) + const transparent = await render({ + ...params, + transparentBounds: true, + }) + const faded = await render( + { ...params, transparentBounds: true }, + 0.35 + ) + assertPixel( + legacy.slice(0, 4), + [0, 0, 0, 1], + `${name}/${kind}: absent flag keeps black borders` + ) + assertPixel( + solid.slice(0, 4), + [0, 0, 0, 1], + `${name}/${kind}: explicit solid borders` + ) + assertPixel( + transparent.slice(0, 4), + backdrop, + `${name}/${kind}: empty border reveals lower layer` + ) + assertPixel( + faded.slice(0, 4), + backdrop, + `${name}/${kind}: empty border stays clear at partial opacity` + ) + if (kind === "image") { + // The square SVG center remains opaque; its hue/coverage must + // not change when only the out-of-bounds behavior changes. + const center = placement.scale === 1 ? 32 : 37 + assertPixel( + transparent.slice(center * 4, center * 4 + 4), + solid.slice(center * 4, center * 4 + 4), + `${name}: in-bounds content is unchanged` + ) + if (transparent[center * 4] < 0.5) + throw new Error(`${name}: opaque center lost its color`) + } + count += 4 + } + if (kind === "image") { + const params = { fitMode: "cover", scale: 0.5, offset: [0.2, 0] } + assertPixel( + await render({ ...params, transparentBounds: true }), + await render({ ...params, transparentBounds: false }), + `${name}: cover mode is unchanged` + ) + count++ + } + } finally { + pass.dispose() + } + } + } + return count + } finally { + input.dispose() + target.dispose() + renderer.dispose() + } +} + +export async function checkMediaBounds(renderProject) { + for (const type of ["image", "video"]) { + const layer = createLayer(type) + if (layer.params.transparentBounds !== true) + throw new Error(`New ${type} layer has opaque borders`) + for (const value of [undefined, false, true]) { + const params = { ...layer.params } + if (value === undefined) delete params.transparentBounds + else params.transparentBounds = value + for (const version of [1, 5, 6]) { + if ( + migrateLayerParams({ ...layer, params }, version) + .transparentBounds !== (value ?? false) + ) { + throw new Error( + `${type} migration changed the explicit or legacy border setting` + ) + } + } + } + } + + const project = parseLabProjectFileValue( + await (await fetch("/fixtures/contained-image-halftone.json")).json() + ) + project.layers = project.layers.filter((layer) => layer.type === "image") + project.selectedLayerId = project.layers[0].id + project.layers[0].params.transparentBounds = true + project.sceneConfig.backgroundColor = "#2468cc" + applyLabProjectFile(project, []) + const saved = buildLabProjectFile() + const state = buildViewerProjectState( + parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))) + ) + if (state.layers[0].params.transparentBounds !== true) + throw new Error("Save/reopen lost transparent borders") + const config = buildShaderExportConfig({ + ...state, + composition: project.composition, + }) + if (config.layers[0].params.transparentBounds !== true) + throw new Error("Shader export lost transparent borders") + + const transparent = await renderProject(saved) + project.layers[0].params.transparentBounds = false + const solid = await renderProject(project) + const pixel = (result, x, y) => + Array.from( + result.image.data.slice( + (y * result.image.width + x) * 4, + (y * result.image.width + x) * 4 + 4 + ) + ) + assertPixel( + pixel(transparent, 4, 96), + [36, 104, 204, 255], + "PNG export reveals scene background" + ) + assertPixel( + pixel(solid, 4, 96), + [0, 0, 0, 255], + "PNG export preserves solid borders" + ) + assertPixel( + pixel(transparent, 128, 96), + pixel(solid, 128, 96), + "PNG export preserves opaque image content" + ) + return { + samples: await checkMediaPasses(), + transparentPng: transparent.png, + solidPng: solid.png, + } +} diff --git a/tests/composition/run.mjs b/tests/composition/run.mjs new file mode 100644 index 00000000..9424ff23 --- /dev/null +++ b/tests/composition/run.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict" +import { mkdir } from "node:fs/promises" +import { resolve, sep } from "node:path" +import { chromium } from "playwright" + +const root = resolve(import.meta.dir, "../..") +const directory = resolve(import.meta.dir) +const update = process.argv.includes("--update") +const artifacts = resolve(root, ".context/composition-test") +await mkdir(artifacts, { recursive: true }) +const bundle = await Bun.build({ + entrypoints: [resolve(directory, "browser.mjs")], + target: "browser", + define: { "process.env": "{}" }, +}) +if (!bundle.success) + throw new AggregateError(bundle.logs, "Composition harness failed to bundle") + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname + if (path === "/") + return new Response( + ``, + { headers: { "Content-Type": "text/html" } } + ) + if (path === "/browser.js") + return new Response(bundle.outputs[0], { + headers: { "Content-Type": "text/javascript" }, + }) + if (path === "/scenes/default/alpha-sample.svg") + return new Response( + Bun.file(resolve(directory, "fixtures/alpha-sample.svg")) + ) + const base = + path.startsWith("/fixtures/") || path.startsWith("/baselines/") + ? directory + : resolve(root, "public") + const file = resolve(base, `.${path}`) + if (!(file.startsWith(`${base}${sep}`) && (await Bun.file(file).exists()))) + return new Response("Not found", { status: 404 }) + return new Response(Bun.file(file)) + }, +}) + +let browser +try { + browser = await chromium.launch({ + headless: true, + args: [ + "--enable-unsafe-webgpu", + "--use-webgpu-adapter=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ], + }) + const page = await browser.newPage() + page.setDefaultTimeout(120_000) + const errors = [] + page.on("pageerror", (error) => { + errors.push(error.message) + console.error(error.message) + }) + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(message.text()) + console.error(message.text()) + } + }) + page.on("requestfailed", (request) => { + const message = `${request.url()}: ${request.failure()?.errorText}` + errors.push(message) + console.error(message) + }) + await page.goto(server.url.href) + await page.waitForFunction(() => typeof window.checkProject === "function") + await page.evaluate(() => document.fonts.load('400 96px "Fixture Mono"')) + assert.ok( + await page.evaluate(async () => !!(await navigator.gpu?.requestAdapter())), + "WebGPU is required; no fallback or skipped tests" + ) + console.log( + "GPU adapter:", + await page.evaluate(async () => { + const adapter = await navigator.gpu.requestAdapter() + return adapter + ? { + vendor: adapter.info.vendor, + architecture: adapter.info.architecture, + device: adapter.info.device, + description: adapter.info.description, + } + : null + }) + ) + + console.log( + "Existing project:", + await page.evaluate(() => window.checkExistingProject()) + ) + const blends = await page.evaluate(() => window.checkLegacyBlendContract()) + const blendPath = resolve(directory, "baselines/legacy-blends.json") + for (const sample of blends) { + assert.ok( + sample.rgba.every(Number.isFinite), + `Non-finite pixel: ${JSON.stringify(sample)}` + ) + assert.equal(sample.rgba[3], 1, "Legacy compositor is opaque") + } + const half = blends.length / 2 + for (let i = 0; i < half; i++) { + assert.deepEqual( + blends[i].rgba, + blends[i + half].rgba, + "Editor/runtime blend mismatch" + ) + } + if (update) { + await Bun.write(blendPath, `${JSON.stringify(blends, null, 2)}\n`) + } else { + const expected = await Bun.file(blendPath).json() + assert.equal(blends.length, expected.length) + for (const [index, sample] of blends.entries()) { + const { rgba, ...identity } = sample + const { rgba: expectedRgba, ...expectedIdentity } = expected[index] + assert.deepEqual(identity, expectedIdentity) + for (let channel = 0; channel < 4; channel++) { + assert.ok( + Math.abs(rgba[channel] - expectedRgba[channel]) < 0.00001, + `Legacy blend changed: ${JSON.stringify(sample)}` + ) + } + } + } + console.log(`PASS ${blends.length} editor/runtime blend and mask samples`) + for (const name of [ + "legacy-text-mask", + "solid-text-background", + "transparent-text-screen", + "contained-image-halftone", + ]) { + const result = await page.evaluate( + ({ name, update }) => window.checkProject(name, update), + { name, update } + ) + const output = update + ? resolve(directory, `baselines/${name}.png`) + : resolve(artifacts, `${name}.png`) + await Bun.write(output, Buffer.from(result.png.split(",")[1], "base64")) + assert.equal( + result.baselineDiff?.changedPixels ?? 0, + 0, + `${name}: ${JSON.stringify(result.baselineDiff)}; actual image: ${output}` + ) + console.log( + `PASS ${name}: preview/export and reopen match; ${result.colors} colors` + ) + } + const mediaBounds = await page.evaluate(() => window.checkMediaBounds()) + await Bun.write( + resolve(artifacts, "transparent-media-bounds.png"), + Buffer.from(mediaBounds.transparentPng.split(",")[1], "base64") + ) + await Bun.write( + resolve(artifacts, "solid-media-bounds.png"), + Buffer.from(mediaBounds.solidPng.split(",")[1], "base64") + ) + console.log( + `PASS media bounds: ${mediaBounds.samples} editor/runtime image/video samples, migration, save/reopen, shader export, and PNG export` + ) + const alphaSamples = await page.evaluate(() => window.checkAlphaCompositing()) + console.log( + `PASS ${alphaSamples} alpha samples: source-over, effects, pass materials, grading, and the runtime texture pipeline` + ) + const groups = await page.evaluate(() => window.checkGroups()) + await Bun.write( + resolve(artifacts, "isolated-groups.png"), + Buffer.from(groups.png.split(",")[1], "base64") + ) + console.log( + `PASS ${groups.samples} group checks: isolation, nesting, opacity, ordering, lifecycle, and editor PNG export` + ) + assert.deepEqual(errors, [], "Browser or GPU errors occurred") + console.log( + update + ? "Baseline capture complete. Review images before committing." + : "Composition baselines passed." + ) +} finally { + await browser?.close() + server.stop(true) +} diff --git a/tests/composition/tsconfig.json b/tests/composition/tsconfig.json new file mode 100644 index 00000000..f5e2740b --- /dev/null +++ b/tests/composition/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["../../src/*"], + "@runtime/*": ["../../packages/shader-lab-react/src/*"] + } + } +}