Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/alpha-compositing-foundation.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/isolated-group-rendering.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/transparent-media-bounds.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- main
- git-chad/shader-lab-v3-plan
- git-chad/v3-*
push:
branches:
- main
Expand Down Expand Up @@ -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
12 changes: 9 additions & 3 deletions V3-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions packages/shader-lab-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/shader-lab-react/src/ambient/three-webgpu.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ declare module "three/webgpu" {
import type {
Box3,
Camera,
Color,
ColorRepresentation,
Material,
Scene,
Expand Down Expand Up @@ -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<void>
clear(): void
getClearAlpha(): number
getClearColor(target: Color): Color
copyTextureToTexture(
srcTexture: Texture,
dstTexture: Texture,
Expand Down
29 changes: 28 additions & 1 deletion packages/shader-lab-react/src/renderer/blend-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,16 @@ export type MaskNodeConfig = {
source: string
}

export type CompositionRole = "source" | "effect"

export function buildBlendNode(
mode: string,
base: Node,
blend: Node,
opacity: Node,
compositeMode: "filter" | "mask" = "filter",
maskConfig?: MaskNodeConfig,
role: CompositionRole = "source",
): Node {
const baseRgb = base.rgb
const blendRgb = blend.rgb
Expand Down Expand Up @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions packages/shader-lab-react/src/renderer/composition-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** Internal render tree; editor persistence and public shader configs remain flat. */
export interface CompositionGroup<Layer> {
blendMode: string
children: CompositionNode<Layer>[]
id: string
kind: "group"
opacity: number
visible: boolean
}

export type CompositionNode<Layer> = Layer | CompositionGroup<Layer>

export const MAX_COMPOSITION_GROUP_DEPTH = 8

export function isCompositionGroup<Layer>(
node: CompositionNode<Layer>
): node is CompositionGroup<Layer> {
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<Layer>(
nodes: CompositionNode<Layer>[],
getLayerId: (layer: Layer) => string
): CompositionNode<Layer>[] {
const result: CompositionNode<Layer>[] = []
const ids = new Set<string>()
function visit(entries: CompositionNode<Layer>[], 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<Layer>(
nodes: CompositionNode<Layer>[],
depth = 0
): CompositionNode<Layer>[] {
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
)
}
3 changes: 2 additions & 1 deletion packages/shader-lab-react/src/renderer/contracts.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,7 +17,7 @@ export interface ProjectClock {

export interface RendererFrame {
clock: ProjectClock
layers: ShaderLabLayerConfig[]
layers: CompositionNode<ShaderLabLayerConfig>[]
logicalSize: RendererSize
outputSize: RendererSize
pixelRatio: number
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
},

Expand Down
Loading
Loading