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/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
10 changes: 7 additions & 3 deletions V3-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ 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.

## 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
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
9 changes: 8 additions & 1 deletion packages/shader-lab-react/src/renderer/media-pass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand Down
15 changes: 14 additions & 1 deletion packages/shader-lab-react/src/renderer/pass-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -286,6 +298,7 @@ export class PassNode {
source: this.maskSource,
}
: undefined,
this.compositionRole,
) as Node
}

Expand Down
7 changes: 7 additions & 0 deletions packages/shader-lab-react/src/renderer/pipeline-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,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),
Expand Down Expand Up @@ -441,6 +442,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 =
Expand Down
10 changes: 10 additions & 0 deletions src/lib/editor/config/layer-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/lib/editor/project-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
29 changes: 28 additions & 1 deletion 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
Loading
Loading