diff --git a/cloudy/api/cloudy.klib.api b/cloudy/api/cloudy.klib.api index 700c9770..f5126313 100644 --- a/cloudy/api/cloudy.klib.api +++ b/cloudy/api/cloudy.klib.api @@ -239,6 +239,17 @@ final object com.skydoves.cloudy/LiquidGlassShaderSource { // com.skydoves.cloud final fun (): kotlin/String // com.skydoves.cloudy/LiquidGlassShaderSource.SKSL.|(){}[0] } +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_BranchBuilder$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_BranchBuilder$stableprop|#static{}com_skydoves_cloudy_edsl_BranchBuilder$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalFloat1$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalFloat1$stableprop|#static{}com_skydoves_cloudy_edsl_LocalFloat1$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalFloat2$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalFloat2$stableprop|#static{}com_skydoves_cloudy_edsl_LocalFloat2$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalHalf4$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalHalf4$stableprop|#static{}com_skydoves_cloudy_edsl_LocalHalf4$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction1$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction1$stableprop|#static{}com_skydoves_cloudy_edsl_ShaderFunction1$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction2$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction2$stableprop|#static{}com_skydoves_cloudy_edsl_ShaderFunction2$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction3$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction3$stableprop|#static{}com_skydoves_cloudy_edsl_ShaderFunction3$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction4$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction4$stableprop|#static{}com_skydoves_cloudy_edsl_ShaderFunction4$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction5$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction5$stableprop|#static{}com_skydoves_cloudy_edsl_ShaderFunction5$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderValueType$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderValueType$stableprop|#static{}com_skydoves_cloudy_edsl_ShaderValueType$stableprop[0] +final val com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_WhenScope$stableprop // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_WhenScope$stableprop|#static{}com_skydoves_cloudy_edsl_WhenScope$stableprop[0] final val com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_Overlay$stableprop // com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_Overlay$stableprop|#static{}com_skydoves_cloudy_internal_Stage_Overlay$stableprop[0] final val com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_PlatformFilter$stableprop // com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_PlatformFilter$stableprop|#static{}com_skydoves_cloudy_internal_Stage_PlatformFilter$stableprop[0] final val com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_ProgramFilter$stableprop // com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_ProgramFilter$stableprop|#static{}com_skydoves_cloudy_internal_Stage_ProgramFilter$stableprop[0] @@ -274,6 +285,8 @@ final val com.skydoves.cloudy/com_skydoves_cloudy_MirageParams$stableprop // com final val com.skydoves.cloudy/com_skydoves_cloudy_MirageShader$stableprop // com.skydoves.cloudy/com_skydoves_cloudy_MirageShader$stableprop|#static{}com_skydoves_cloudy_MirageShader$stableprop[0] final val com.skydoves.cloudy/com_skydoves_cloudy_MirageShaders$stableprop // com.skydoves.cloudy/com_skydoves_cloudy_MirageShaders$stableprop|#static{}com_skydoves_cloudy_MirageShaders$stableprop[0] final val com.skydoves.cloudy/com_skydoves_cloudy_PlatformBitmap$stableprop // com.skydoves.cloudy/com_skydoves_cloudy_PlatformBitmap$stableprop|#static{}com_skydoves_cloudy_PlatformBitmap$stableprop[0] +final val com.skydoves.cloudy/com_skydoves_cloudy_RainyWindowOptic$stableprop // com.skydoves.cloudy/com_skydoves_cloudy_RainyWindowOptic$stableprop|#static{}com_skydoves_cloudy_RainyWindowOptic$stableprop[0] +final val com.skydoves.cloudy/com_skydoves_cloudy_RainyWindowParams$stableprop // com.skydoves.cloudy/com_skydoves_cloudy_RainyWindowParams$stableprop|#static{}com_skydoves_cloudy_RainyWindowParams$stableprop[0] final val com.skydoves.cloudy/com_skydoves_cloudy_Sky$stableprop // com.skydoves.cloudy/com_skydoves_cloudy_Sky$stableprop|#static{}com_skydoves_cloudy_Sky$stableprop[0] final val com.skydoves.cloudy/com_skydoves_cloudy_SpecularParams$stableprop // com.skydoves.cloudy/com_skydoves_cloudy_SpecularParams$stableprop|#static{}com_skydoves_cloudy_SpecularParams$stableprop[0] final val com.skydoves.cloudy/com_skydoves_cloudy_UColor$stableprop // com.skydoves.cloudy/com_skydoves_cloudy_UColor$stableprop|#static{}com_skydoves_cloudy_UColor$stableprop[0] @@ -293,6 +306,17 @@ final fun (androidx.compose.ui/Modifier).com.skydoves.cloudy/liquidGlass(android final fun (androidx.compose.ui/Modifier).com.skydoves.cloudy/sky(com.skydoves.cloudy/Sky, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui/Modifier // com.skydoves.cloudy/sky|sky@androidx.compose.ui.Modifier(com.skydoves.cloudy.Sky;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun (com.skydoves.cloudy/PlatformBitmap).com.skydoves.cloudy/createCompatible(): com.skydoves.cloudy/PlatformBitmap // com.skydoves.cloudy/createCompatible|createCompatible@com.skydoves.cloudy.PlatformBitmap(){}[0] final fun (com.skydoves.cloudy/PlatformBitmap).com.skydoves.cloudy/dispose() // com.skydoves.cloudy/dispose|dispose@com.skydoves.cloudy.PlatformBitmap(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_BranchBuilder$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_BranchBuilder$stableprop_getter|com_skydoves_cloudy_edsl_BranchBuilder$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalFloat1$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalFloat1$stableprop_getter|com_skydoves_cloudy_edsl_LocalFloat1$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalFloat2$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalFloat2$stableprop_getter|com_skydoves_cloudy_edsl_LocalFloat2$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalHalf4$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_LocalHalf4$stableprop_getter|com_skydoves_cloudy_edsl_LocalHalf4$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction1$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction1$stableprop_getter|com_skydoves_cloudy_edsl_ShaderFunction1$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction2$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction2$stableprop_getter|com_skydoves_cloudy_edsl_ShaderFunction2$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction3$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction3$stableprop_getter|com_skydoves_cloudy_edsl_ShaderFunction3$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction4$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction4$stableprop_getter|com_skydoves_cloudy_edsl_ShaderFunction4$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction5$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderFunction5$stableprop_getter|com_skydoves_cloudy_edsl_ShaderFunction5$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderValueType$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_ShaderValueType$stableprop_getter|com_skydoves_cloudy_edsl_ShaderValueType$stableprop_getter(){}[0] +final fun com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_WhenScope$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.edsl/com_skydoves_cloudy_edsl_WhenScope$stableprop_getter|com_skydoves_cloudy_edsl_WhenScope$stableprop_getter(){}[0] final fun com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_Overlay$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_Overlay$stableprop_getter|com_skydoves_cloudy_internal_Stage_Overlay$stableprop_getter(){}[0] final fun com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_PlatformFilter$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_PlatformFilter$stableprop_getter|com_skydoves_cloudy_internal_Stage_PlatformFilter$stableprop_getter(){}[0] final fun com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_ProgramFilter$stableprop_getter(): kotlin/Int // com.skydoves.cloudy.internal/com_skydoves_cloudy_internal_Stage_ProgramFilter$stableprop_getter|com_skydoves_cloudy_internal_Stage_ProgramFilter$stableprop_getter(){}[0] @@ -329,6 +353,8 @@ final fun com.skydoves.cloudy/com_skydoves_cloudy_MirageParams$stableprop_getter final fun com.skydoves.cloudy/com_skydoves_cloudy_MirageShader$stableprop_getter(): kotlin/Int // com.skydoves.cloudy/com_skydoves_cloudy_MirageShader$stableprop_getter|com_skydoves_cloudy_MirageShader$stableprop_getter(){}[0] final fun com.skydoves.cloudy/com_skydoves_cloudy_MirageShaders$stableprop_getter(): kotlin/Int // com.skydoves.cloudy/com_skydoves_cloudy_MirageShaders$stableprop_getter|com_skydoves_cloudy_MirageShaders$stableprop_getter(){}[0] final fun com.skydoves.cloudy/com_skydoves_cloudy_PlatformBitmap$stableprop_getter(): kotlin/Int // com.skydoves.cloudy/com_skydoves_cloudy_PlatformBitmap$stableprop_getter|com_skydoves_cloudy_PlatformBitmap$stableprop_getter(){}[0] +final fun com.skydoves.cloudy/com_skydoves_cloudy_RainyWindowOptic$stableprop_getter(): kotlin/Int // com.skydoves.cloudy/com_skydoves_cloudy_RainyWindowOptic$stableprop_getter|com_skydoves_cloudy_RainyWindowOptic$stableprop_getter(){}[0] +final fun com.skydoves.cloudy/com_skydoves_cloudy_RainyWindowParams$stableprop_getter(): kotlin/Int // com.skydoves.cloudy/com_skydoves_cloudy_RainyWindowParams$stableprop_getter|com_skydoves_cloudy_RainyWindowParams$stableprop_getter(){}[0] final fun com.skydoves.cloudy/com_skydoves_cloudy_Sky$stableprop_getter(): kotlin/Int // com.skydoves.cloudy/com_skydoves_cloudy_Sky$stableprop_getter|com_skydoves_cloudy_Sky$stableprop_getter(){}[0] final fun com.skydoves.cloudy/com_skydoves_cloudy_SpecularParams$stableprop_getter(): kotlin/Int // com.skydoves.cloudy/com_skydoves_cloudy_SpecularParams$stableprop_getter|com_skydoves_cloudy_SpecularParams$stableprop_getter(){}[0] final fun com.skydoves.cloudy/com_skydoves_cloudy_UColor$stableprop_getter(): kotlin/Int // com.skydoves.cloudy/com_skydoves_cloudy_UColor$stableprop_getter|com_skydoves_cloudy_UColor$stableprop_getter(){}[0] diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageParams.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageParams.kt index fed0f131..4ce6817a 100644 --- a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageParams.kt +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageParams.kt @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(ExperimentalMirage::class) + package com.skydoves.cloudy import androidx.compose.ui.geometry.Offset @@ -20,6 +22,13 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.TileMode +import com.skydoves.cloudy.edsl.Expression +import com.skydoves.cloudy.edsl.Float1 +import com.skydoves.cloudy.edsl.Float2 +import com.skydoves.cloudy.edsl.Float4 +import com.skydoves.cloudy.edsl.Half4 +import com.skydoves.cloudy.edsl.ShaderType +import com.skydoves.cloudy.edsl.UniformRef import com.skydoves.cloudy.internal.UniformEntry import kotlin.properties.PropertyDelegateProvider import kotlin.properties.ReadOnlyProperty @@ -38,8 +47,10 @@ import kotlin.reflect.KProperty * Lifetime: the engine creates one instance per node and reuses it — every param write happens on * the single draw-phase thread, so no synchronization and no per-frame allocation. * - * A handle here is a plain typed slot, not a shader expression: it carries the per-draw value and - * its binding slot, nothing more. + * A handle plays two roles from one object: at draw time it carries the per-draw value and its binding + * slot; inside a traced kernel body it *is* the shader expression that reads that uniform — the typed + * handles ([UFloat] etc.) implement the matching value interface ([Float1] etc.) and hand back a + * [UniformRef] node, so a body writes `shadow.rgb` with no lift/convert step. */ @ExperimentalMirage public abstract class MirageParams { @@ -155,19 +166,25 @@ public sealed interface UniformHandle { public val slot: Int } -/** A scalar `float` uniform slot. */ +/** A scalar `float` uniform slot; a [Float1] expression inside a traced body. */ @ExperimentalMirage public class UFloat internal constructor(override val slot: Int, public var value: Float) : - UniformHandle { + UniformHandle, + Float1 { + override val e: Expression get() = UniformRef(slot, ShaderType.Float1) + public operator fun invoke(v: Float) { value = v } } -/** A `float2` uniform slot carrying a coordinate or direction. */ +/** A `float2` uniform slot carrying a coordinate or direction; a [Float2] expression in a body. */ @ExperimentalMirage public class UOffset internal constructor(override val slot: Int, public var value: Offset) : - UniformHandle { + UniformHandle, + Float2 { + override val e: Expression get() = UniformRef(slot, ShaderType.Float2) + public operator fun invoke(v: Offset) { value = v } @@ -177,10 +194,13 @@ public class UOffset internal constructor(override val slot: Int, public var val } } -/** A `float2` uniform slot carrying a size. */ +/** A `float2` uniform slot carrying a size; a [Float2] expression in a body. */ @ExperimentalMirage public class USize internal constructor(override val slot: Int, public var value: Size) : - UniformHandle { + UniformHandle, + Float2 { + override val e: Expression get() = UniformRef(slot, ShaderType.Float2) + public operator fun invoke(v: Size) { value = v } @@ -209,10 +229,13 @@ public class UVec3 internal constructor(override val slot: Int, public var value } } -/** A `float4` uniform slot. The backing array is always length 4. */ +/** A `float4` uniform slot; a [Float4] expression in a body. The backing array is always length 4. */ @ExperimentalMirage public class UVec4 internal constructor(override val slot: Int, public var value: FloatArray) : - UniformHandle { + UniformHandle, + Float4 { + override val e: Expression get() = UniformRef(slot, ShaderType.Float4) + public operator fun invoke(v: FloatArray) { require(v.size == 4) { "UVec4 value must have size 4, was ${v.size}" } value = v.copyOf() @@ -234,10 +257,13 @@ public class UFloatArray internal constructor( } } -/** A `layout(color) vec4` uniform slot. */ +/** A `layout(color) vec4` uniform slot; a [Half4] color expression in a body. */ @ExperimentalMirage public class UColor internal constructor(override val slot: Int, public var value: Color) : - UniformHandle { + UniformHandle, + Half4 { + override val e: Expression get() = UniformRef(slot, ShaderType.Half4) + public operator fun invoke(v: Color) { value = v } diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageShader.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageShader.kt index 58e18059..01b37c5f 100644 --- a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageShader.kt +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageShader.kt @@ -13,8 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(ExperimentalMirage::class) + package com.skydoves.cloudy +import com.skydoves.cloudy.edsl.Argument +import com.skydoves.cloudy.edsl.Float2 +import com.skydoves.cloudy.edsl.Half4 +import com.skydoves.cloudy.edsl.ShaderModule +import com.skydoves.cloudy.edsl.ShaderType +import com.skydoves.cloudy.edsl.emitColorizeKernel +import com.skydoves.cloudy.edsl.emitCompositeOrGenerateMain +import com.skydoves.cloudy.edsl.trace import com.skydoves.cloudy.internal.ShaderCategory /** @@ -122,6 +132,94 @@ public sealed class MirageShader

protected constructor( sksl: String, ): GeneratorShader

= GeneratorShader(name, paramsFactory, agsl, sksl) + /** + * Creates a [ColorizeShader] from a **traced body lambda** instead of two hand-written dialect + * strings. The body runs once at construction with the shader's [MirageParams] as receiver (so it + * reads uniform handles bare) and `src` as the sampled pixel; its final expression is the returned + * color. AGSL is a public-runtime-effect-restricted profile of SkSL, so the traced source compiles + * unchanged under either dialect; the emitter prints it once and hands it to the string [colorize] + * overload, so codegen, caching, and equality are unchanged. + */ + public fun

colorize( + name: String, + paramsFactory: () -> P, + body: P.(src: Half4) -> Half4, + ): ColorizeShader

{ + val kernel = + emitColorizeKernel( + traceBody(paramsFactory) { + body(SRC_ARGUMENT) + }, + uniformNames(paramsFactory), + ) + return colorize(name, paramsFactory, agsl = kernel, sksl = kernel) + } + + /** + * Creates a [CompositeShader] from a **traced body lambda**. The body runs once with the shader's + * [MirageParams] as receiver and the fragment position `xy`; it samples content freely via + * `sampleContent(...)` and returns the final color. See [colorize] (body overload) for the trace → + * single-kernel-text pipeline. + */ + public fun

composite( + name: String, + paramsFactory: () -> P, + body: P.(xy: Float2) -> Half4, + ): CompositeShader

{ + val kernel = + emitCompositeOrGenerateMain( + traceBody(paramsFactory) { + body(XY_ARGUMENT) + }, + uniformNames(paramsFactory), + ) + return composite(name, paramsFactory, agsl = kernel, sksl = kernel) + } + + /** + * Creates a [GeneratorShader] from a **traced body lambda**. The body synthesizes pixels from + * uniforms and `xy` only — there is no content sampler, so referencing content is a compile error. + * See [colorize] (body overload) for the trace → single-kernel-text pipeline. + */ + public fun

generate( + name: String, + paramsFactory: () -> P, + body: P.(xy: Float2) -> Half4, + ): GeneratorShader

{ + val kernel = + emitCompositeOrGenerateMain( + traceBody(paramsFactory) { + body(XY_ARGUMENT) + }, + uniformNames(paramsFactory), + ) + return generate(name, paramsFactory, agsl = kernel, sksl = kernel) + } + + /** The uniform identifiers, in declaration (= bind) order, of a probe minted from [paramsFactory]. */ + private fun

uniformNames(paramsFactory: () -> P): List = + paramsFactory().schemaEntries.map { it.name } + + /** + * Runs [body] against a fresh probe [MirageParams] under one [trace], returning the recorded + * kernel. The probe is discarded; the emitted source depends only on the uniform *schema* (slots), + * not any per-instance default, so a probe at its own defaults yields the same text as any live + * instance would — which is why the five thin-film looks share one kernel program. + */ + private fun

traceBody( + paramsFactory: () -> P, + body: P.() -> Half4, + ): ShaderModule { + val (result, ctx) = trace(paramsFactory(), body) + return ShaderModule(ctx.statements.toList(), result.e, ctx.helpers.toList()) + } + + /** The `src` pixel argument a Colorize body receives. */ + private val SRC_ARGUMENT: Half4 get() = Half4(Argument("src", ShaderType.Half4)) + + /** The `xy` fragment-position argument a Composite/Generate body receives. */ + private val XY_ARGUMENT: Float2 get() = Float2(Argument("xy", ShaderType.Float2)) + /** * Creates a raw [FilterShader] — an escape hatch with **no codegen**. * diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageShaders.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageShaders.kt index 93f63ae7..44136ef4 100644 --- a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageShaders.kt +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/MirageShaders.kt @@ -13,19 +13,64 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(ExperimentalMirage::class) + package com.skydoves.cloudy import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import com.skydoves.cloudy.internal.CHROMATIC_KERNEL_AGSL -import com.skydoves.cloudy.internal.CHROMATIC_KERNEL_SKSL -import com.skydoves.cloudy.internal.DUOTONE_KERNEL_AGSL -import com.skydoves.cloudy.internal.DUOTONE_KERNEL_SKSL -import com.skydoves.cloudy.internal.FOIL_KERNEL_AGSL -import com.skydoves.cloudy.internal.FOIL_KERNEL_SKSL -import com.skydoves.cloudy.internal.SPECULAR_KERNEL_AGSL -import com.skydoves.cloudy.internal.SPECULAR_KERNEL_SKSL +import com.skydoves.cloudy.edsl.Float2 +import com.skydoves.cloudy.edsl.Half4 +import com.skydoves.cloudy.edsl.If +import com.skydoves.cloudy.edsl.a +import com.skydoves.cloudy.edsl.abs +import com.skydoves.cloudy.edsl.and +import com.skydoves.cloudy.edsl.boxRoundedSDF +import com.skydoves.cloudy.edsl.clamp +import com.skydoves.cloudy.edsl.cos +import com.skydoves.cloudy.edsl.div +import com.skydoves.cloudy.edsl.dot +import com.skydoves.cloudy.edsl.exp +import com.skydoves.cloudy.edsl.float1 +import com.skydoves.cloudy.edsl.float2 +import com.skydoves.cloudy.edsl.float3 +import com.skydoves.cloudy.edsl.float4 +import com.skydoves.cloudy.edsl.floor +import com.skydoves.cloudy.edsl.foilHash +import com.skydoves.cloudy.edsl.fract +import com.skydoves.cloudy.edsl.greaterThan +import com.skydoves.cloudy.edsl.greaterThanEqual +import com.skydoves.cloudy.edsl.guard +import com.skydoves.cloudy.edsl.half +import com.skydoves.cloudy.edsl.half3 +import com.skydoves.cloudy.edsl.half4 +import com.skydoves.cloudy.edsl.length +import com.skydoves.cloudy.edsl.lensNormalDirection +import com.skydoves.cloudy.edsl.lessThanEqual +import com.skydoves.cloudy.edsl.local +import com.skydoves.cloudy.edsl.luma +import com.skydoves.cloudy.edsl.max +import com.skydoves.cloudy.edsl.min +import com.skydoves.cloudy.edsl.minus +import com.skydoves.cloudy.edsl.mirageTime +import com.skydoves.cloudy.edsl.mix +import com.skydoves.cloudy.edsl.normalize +import com.skydoves.cloudy.edsl.plus +import com.skydoves.cloudy.edsl.pow +import com.skydoves.cloudy.edsl.processColor +import com.skydoves.cloudy.edsl.rgb +import com.skydoves.cloudy.edsl.sampleContent +import com.skydoves.cloudy.edsl.signSelect +import com.skydoves.cloudy.edsl.sin +import com.skydoves.cloudy.edsl.smoothstep +import com.skydoves.cloudy.edsl.sqrt +import com.skydoves.cloudy.edsl.step +import com.skydoves.cloudy.edsl.times +import com.skydoves.cloudy.edsl.unaryMinus +import com.skydoves.cloudy.edsl.x +import com.skydoves.cloudy.edsl.xyz +import com.skydoves.cloudy.edsl.y /** * Bundled [MirageShader] presets — the catalog of ready-to-apply looks. @@ -50,18 +95,192 @@ import com.skydoves.cloudy.internal.SPECULAR_KERNEL_SKSL @ExperimentalMirage public object MirageShaders { + /** + * The Chromatic thin-film body: a superellipse-bevel Newton's-rings tint, alpha-branch blended + * (multiply on transparent, screen glow on opaque), masked to the lens. Held as a lambda so + * [chromaticKernel] can trace + emit it once and every look shares that one kernel text; the body + * depends only on the uniform *schema* (slots), never on a look's default values. + * + * Declared first (above [chromaticKernel], its sole reader): a Kotlin `object` initializes its + * properties top-to-bottom, so a `val` read before its own declaration sees an uninitialized field. + */ + private val chromaticBody: ChromaticParams.(Float2) -> Half4 = { xy -> + val smoothEdgePx = 1.5f // SMOOTH_EDGE_PX + val chromaOpdBase = 0.10f + val chromaThickMix = 0.55f + val chromaRimPow = 3.0f + val chromaSePow = 4.0f + + val halfDim = lensSize * 0.5f + val r = min(cornerRadius, min(halfDim.x, halfDim.y)) + val p = xy - lensCenter + val sdf = boxRoundedSDF(p, halfDim, r) + + guard(sdf greaterThan smoothEdgePx) { sampleContent(xy) } + + var pixel by local(sampleContent(xy)) + + val minHalf = min(halfDim.x, halfDim.y) + val cLightVec = normalize(iLight) + val q = abs(p) / float2(max(halfDim.x, 1f), max(halfDim.y, 1f)) + val s2 = float2(signSelect(p.x), signSelect(p.y)) + val f = pow(pow(q.x, chromaSePow) + pow(q.y, chromaSePow), 1f / chromaSePow) + val cDir = normalize( + s2 * float2( + chromaSePow * pow(q.x, chromaSePow - 1f) / max(halfDim.x, 1f), + chromaSePow * pow(q.y, chromaSePow - 1f) / max(halfDim.y, 1f), + ) + float2(1.0e-4f, 1.0e-4f), + ) + val t = clamp(f, 0f, 1f) + val nCos = 1f - t + val nSin = sqrt(max(1f - nCos * nCos, 0f)) + val cN = normalize(float3(cDir * nCos, nSin + 1.0e-3f)) + val cL = normalize(float3(cLightVec, 0.55f)) + + val cosT = clamp(dot(cN, cL), 0f, 1f) + val thick = 1f - nCos + val ringTerm = thick / max(1f - 0.6f * cosT, 1.0e-2f) + val opdDrive = mix(cosT, ringTerm, chromaThickMix) + val opd = opdDrive * chromaticGain + chromaOpdBase + val interf = + float3(0.5f, 0.5f, 0.5f) + + float3(0.5f, 0.5f, 0.5f) * cos(6.28318530718f * opd * chromaticKRGB.xyz) + val metalRGB = float3(chromaticFloor) + (1f - chromaticFloor) * interf + val sat = exp(-opd * chromaticWashout) + val thinFilm = mix(float3(1f, 1f, 1f), metalRGB, clamp(sat, 0f, 1f)) + val rimBoost = chromaticRimBoost * pow(clamp(thick, 0f, 1f), chromaRimPow) + val chromaRGB = mix(thinFilm, float3(1f, 1f, 1f), clamp(rimBoost, 0f, 1f)) + + val cFocal = cLightVec * (minHalf * 0.55f) + val cPoolR = max(minHalf * chromaticPoolFrac, 1f) + val cPool = 1f - smoothstep(0f, cPoolR, length(p - cFocal)) + val poolNorm = clamp(cPool * cPool, 0f, 1f) + val chroma = chromaticIntensity * mix(1f, poolNorm, clamp(chromaticModulate, 0f, 1f)) + + val cChroma = half(clamp(chroma, 0f, 1f)) + val cChromaRGB = half3(chromaRGB) * cChroma + val cOnWhite = half3(chromaRGB) + val pixelBeforeBlend = pixel + val cOnSrc = half3(1f) - (half3(1f) - pixelBeforeBlend.rgb) * (half3(1f) - cChromaRGB) + pixel = half4(mix(cOnWhite, cOnSrc, pixelBeforeBlend.a), max(pixelBeforeBlend.a, cChroma)) + + val alpha = 1f - smoothstep(-smoothEdgePx * 0.5f, smoothEdgePx * 0.5f, sdf) + val bg = sampleContent(xy) + mix(bg, pixel, alpha) + } + + /** + * The Chromatic kernel text, emitted **once** from [chromaticBody] and shared by every named look + * ([Chromatic], [OilSlick], [SoapBubble], [MetallicFoil], [Pearl]) — the kernel source depends only + * on the params *schema* (uniform slots), never on a look's default values, so all five looks + * compile to one GPU program (the raster tests assert `oil.source == soap.source == ...`). Tracing + * the body once and reusing the string keeps that guarantee free of any emit-determinism assumption. + * + * Declared first in this object: [chromatic] (called while initializing [Chromatic]/[OilSlick]/etc. + * below) reads this eagerly, and a Kotlin `object`'s properties initialize top-to-bottom — a `val` + * declared after its first reader sees an uninitialized backing field (an NPE, not a compile error), + * so this can't simply live next to [chromatic] where it's used. + */ + private val chromaticKernel: String = + MirageShader.composite("chromatic", { + ChromaticParams(0f, 0f, floatArrayOf(0f, 0f, 0f, 0f), 0f, 0f, 0f, 0f) + }, chromaticBody).agsl + /** * The liquid-glass specular glint (moving focal hotspot + Blinn rim). A [CompositeShader] because it * shares intermediates (SDF, bevel normal) across the refraction and specular terms. Its defaults * are the `GlowTuning` values, so applying it over the default lens framing reproduces the built-in * `liquidGlass` glint bit-for-bit. + * + * Authored as an eDSL body lambda — a mutable `pixel` ([local]) reassigned from a `content.eval` + * fallback and again inside a non-exiting [If], a `&&`-gated highlight, and multiple + * [sampleContent] taps. */ - public val Specular: CompositeShader = MirageShader.composite( - name = "specular", - paramsFactory = ::SpecularParams, - agsl = SPECULAR_KERNEL_AGSL, - sksl = SPECULAR_KERNEL_SKSL, - ) + public val Specular: CompositeShader = + MirageShader.composite("specular", ::SpecularParams) { xy -> + val smoothEdgePx = 1.5f // SMOOTH_EDGE_PX, the preamble's shared edge-blend constant + val specSePow = 4.0f // SPEC_SE_POW + + val halfDim = lensSize * 0.5f + val r = min(cornerRadius, min(halfDim.x, halfDim.y)) + val p = xy - lensCenter + val sdf = boxRoundedSDF(p, halfDim, r) + + guard(sdf greaterThan smoothEdgePx) { sampleContent(xy) } + + val normal = lensNormalDirection(p, halfDim, r) + + // The `{ ... }` scratch block computing sampleXY: its locals (minDim/depth/curvature/bend) are + // scoped to that block in the source but never read outside it, so tracing them as ordinary + // vals here is observationally identical — nothing after this reads them. + val minDim = min(halfDim.x, halfDim.y) + val depth = clamp(-sdf / (minDim * 0.25f), 0f, 1f) + val curvature = 1f - depth + val bend = 1f - sqrt(1f - curvature * curvature) + val sampleXY = xy - normal * (bend * 0.25f * minDim) + + var pixel by local(sampleContent(sampleXY)) + If(pixel.a lessThanEqual 0f) { pixel = sampleContent(xy) } + pixel = half4(processColor(pixel.rgb, 1f, 1f, float4(0f, 0f, 0f, 0f)), pixel.a) + + val edge = float1(0.2f) + + If((edge greaterThan 0f) and (specStrength greaterThan 0f)) { + val lightVec = normalize(iLight) + val minHalf = min(halfDim.x, halfDim.y) + val q = abs(p) / float2(max(halfDim.x, 1f), max(halfDim.y, 1f)) + val s2 = float2(signSelect(p.x), signSelect(p.y)) + val seF = pow(pow(q.x, specSePow) + pow(q.y, specSePow), 1f / specSePow) + val specDir2 = normalize( + s2 * float2( + specSePow * pow(q.x, specSePow - 1f) / max(halfDim.x, 1f), + specSePow * pow(q.y, specSePow - 1f) / max(halfDim.y, 1f), + ) + float2(1.0e-4f, 1.0e-4f), + ) + + val t = clamp(seF / max(specDomeFrac, 1.0e-2f), 0f, 1f) + val nCos = 1f - t + val nSin = sqrt(max(1f - nCos * nCos, 0f)) + val nn = normalize(float3(specDir2 * nCos, nSin + 1.0e-3f)) + + val ll = normalize(float3(lightVec, specLightZ)) + val vv = float3(0f, 0f, 1f) + + val focal = lightVec * (minHalf * specFocalK) + val poolR = max(minHalf * specPoolFrac, 1f) + val poolD = length(p - focal) + val pool = 1f - smoothstep(0f, poolR, poolD) + val inside = 1f - smoothstep(-6f, 0f, sdf) + val focalPool = pool * pool * specStrength * specPoolGain * inside + + val ndl = max(dot(nn, ll), 0f) + val bodySheen = pow(ndl, specBodyPower) * specStrength * specBodyGain + + val hh = normalize(ll + vv) + val rimBand = smoothstep(-max(specWidthPx, 1f), 0f, sdf) + val glint = pow(max(dot(nn, hh), 0f), specPower) * specStrength + val rim = glint * rimBand + + val lb = normalize(float3(-lightVec, specLightZ)) + val back = pow(max(dot(nn, lb), 0f), specPower) * specStrength * rimBand * 0.25f + + val hp = fract((p / minHalf) * 0.5f + 0.5f) + val dn = fract(sin(dot(hp, float2(12.9898f, 78.233f))) * 43758.5453f) - 0.5f + + val body = focalPool + bodySheen + dn * (1f / 255f) * specStrength + val rimMix = clamp(specRimMix, 0f, 1f) + val highlight = body * (1f - rimMix) + (rim + back) * rimMix + + pixel = half4( + pixel.rgb + (half3(1f) - pixel.rgb) * clamp(highlight, 0f, 1f), + pixel.a, + ) + } + + val alpha = 1f - smoothstep(-smoothEdgePx * 0.5f, smoothEdgePx * 0.5f, sdf) + val bg = sampleContent(xy) + mix(bg, pixel, alpha) + } /** The default thin-film iridescence look — the [chromatic] factory at its defaults. */ public val Chromatic: CompositeShader = chromatic() @@ -110,27 +329,75 @@ public object MirageShaders { /** * A foil overlay — a content-free [GeneratorShader] (glare + flowing rainbow + anti-aliased sparkle) * drawn over the content. Declare it via `overlay(MirageShaders.Foil)` so it composites on top of any - * filter result; its `mirageTime` reference lets the clock drive the sparkle shimmer. + * filter result; its [mirageTime] reference lets the clock drive the sparkle shimmer. + * + * Authored as an eDSL body lambda — a Generate `main(float2 xy)` with an early-return [guard] and a + * user-defined helper ([foilHash]). */ - public val Foil: GeneratorShader = MirageShader.generate( - name = "foil", - paramsFactory = ::FoilParams, - agsl = FOIL_KERNEL_AGSL, - sksl = FOIL_KERNEL_SKSL, - ) + public val Foil: GeneratorShader = + MirageShader.generate("foil", ::FoilParams) { xy -> + val smoothEdgePx = 1.5f // SMOOTH_EDGE_PX + + val halfDim = lensSize * 0.5f + val r = min(cornerRadius, min(halfDim.x, halfDim.y)) + val p = xy - lensCenter + val sdf = boxRoundedSDF(p, halfDim, r) + + guard(sdf greaterThan smoothEdgePx) { half4(0f) } + + val minHalf = min(halfDim.x, halfDim.y) + val cLightVec = normalize(iLight) + val pNorm = p / minHalf + val t = clamp(max(-sdf, 0f) / max(minHalf, 1f), 0f, 1f) + + val along = dot(pNorm, cLightVec) + val glare = smoothstep(0.2f, 1f, along) * (1f - t) + val dome = (1f - smoothstep(0f, 1f, length(pNorm))) * 0.5f + + val hueF = fract(along * foilBands + foilPhase + 0.05f * mirageTime) + val hsv = clamp( + abs(fract(float3(hueF) + float3(0f, 2f / 3f, 1f / 3f)) * 6f - 3f) - 1f, + 0f, + 1f, + ) + val opd = (0.5f + 0.5f * t) * chromaticGain + val film = + float3(0.5f, 0.5f, 0.5f) + + float3(0.5f, 0.5f, 0.5f) * cos(6.28318530718f * opd * float3(1f, 1.18f, 1.42f)) + val rainbow = mix(hsv, film, 0.4f) + + val cell = floor(pNorm * sparkleDensity) + val h = foilHash(cell) + val cellUv = fract(pNorm * sparkleDensity) - float2(0.5f, 0.5f) + val d = length(cellUv) + val aa = clamp(sparkleDensity / max(minHalf, 1f), 0.02f, 0.25f) + val dot0 = 1f - smoothstep(0.18f - aa, 0.18f + aa, d) + val twinkle = 0.5f + 0.5f * sin(6.2831853f * (h + 0.3f * mirageTime)) + val spark = step(0.78f, h) * dot0 * twinkle * sparkleAmplitude + + val lum = clamp(glare + dome, 0f, 1f) + val rgb = rainbow * lum + float3(spark) + val a = clamp(lum + spark, 0f, 1f) + val mask = 1f - smoothstep(-smoothEdgePx * 0.5f, smoothEdgePx * 0.5f, sdf) + + half4(half3(rgb) * half(mask), half(a * mask)) + } /** * A point-wise duotone grade: maps luminance onto a [shadow][DuotoneParams.shadow] → * [highlight][DuotoneParams.highlight] gradient and cross-fades by [amount][DuotoneParams.amount]. * A [ColorizeShader], so it fuses cheaply and needs no lens framing. The defaults are a warm * split-tone (deep indigo shadows, cream highlights). + * + * Authored as an eDSL body lambda (a point-wise `kernel(float2 p, half4 src)`), emitted once and + * reused for both dialects (AGSL and SkSL share this authoring surface). */ - public val Duotone: ColorizeShader = MirageShader.colorize( - name = "duotone", - paramsFactory = ::DuotoneParams, - agsl = DUOTONE_KERNEL_AGSL, - sksl = DUOTONE_KERNEL_SKSL, - ) + public val Duotone: ColorizeShader = + MirageShader.colorize("duotone", ::DuotoneParams) { src -> + val g = luma(src.rgb) + val dz = mix(shadow.rgb, highlight.rgb, g) + half4(mix(src.rgb, dz, amount), src.a) + } /** * Builds a thin-film (Newton's-rings) iridescence [CompositeShader] from its per-look parameters. It @@ -169,8 +436,8 @@ public object MirageShaders { rimBoost = rimBoost, ) }, - agsl = CHROMATIC_KERNEL_AGSL, - sksl = CHROMATIC_KERNEL_SKSL, + agsl = chromaticKernel, + sksl = chromaticKernel, ) } diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/RainyWindowShader.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/RainyWindowShader.kt new file mode 100644 index 00000000..3e748914 --- /dev/null +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/RainyWindowShader.kt @@ -0,0 +1,331 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy + +import androidx.compose.ui.graphics.TileMode +import com.skydoves.cloudy.edsl.Argument +import com.skydoves.cloudy.edsl.Float1 +import com.skydoves.cloudy.edsl.Float2 +import com.skydoves.cloudy.edsl.Float3 +import com.skydoves.cloudy.edsl.Half4 +import com.skydoves.cloudy.edsl.If +import com.skydoves.cloudy.edsl.ShaderType +import com.skydoves.cloudy.edsl.a +import com.skydoves.cloudy.edsl.abs +import com.skydoves.cloudy.edsl.clamp +import com.skydoves.cloudy.edsl.defineHelper +import com.skydoves.cloudy.edsl.div +import com.skydoves.cloudy.edsl.dot +import com.skydoves.cloudy.edsl.eval +import com.skydoves.cloudy.edsl.float1 +import com.skydoves.cloudy.edsl.float2 +import com.skydoves.cloudy.edsl.float3 +import com.skydoves.cloudy.edsl.floor +import com.skydoves.cloudy.edsl.fract +import com.skydoves.cloudy.edsl.greaterThan +import com.skydoves.cloudy.edsl.half +import com.skydoves.cloudy.edsl.half3 +import com.skydoves.cloudy.edsl.half4 +import com.skydoves.cloudy.edsl.length +import com.skydoves.cloudy.edsl.local +import com.skydoves.cloudy.edsl.max +import com.skydoves.cloudy.edsl.min +import com.skydoves.cloudy.edsl.minus +import com.skydoves.cloudy.edsl.mirageResolution +import com.skydoves.cloudy.edsl.mirageTime +import com.skydoves.cloudy.edsl.mix +import com.skydoves.cloudy.edsl.mod +import com.skydoves.cloudy.edsl.plus +import com.skydoves.cloudy.edsl.r +import com.skydoves.cloudy.edsl.rgb +import com.skydoves.cloudy.edsl.sampleContent +import com.skydoves.cloudy.edsl.sin +import com.skydoves.cloudy.edsl.smoothstep +import com.skydoves.cloudy.edsl.sqrt +import com.skydoves.cloudy.edsl.times +import com.skydoves.cloudy.edsl.unaryMinus +import com.skydoves.cloudy.edsl.x +import com.skydoves.cloudy.edsl.xy +import com.skydoves.cloudy.edsl.y +import com.skydoves.cloudy.edsl.yx +import com.skydoves.cloudy.edsl.yzx +import com.skydoves.cloudy.edsl.z + +/** + * Rain on steamed glass: running refracting raindrops + condensation you wipe clear with a finger. + * The drop field is the "Heartfelt" shader by Martijn Steinrucken (BigWings), ported to the mirage + * eDSL. A full-bleed [CompositeShader]: it samples the content freely and reads a [wipeMask][RainyWindowParams.wipeMask] + * texture child for the finger-wipe fog mask. + */ +@ExperimentalMirage +public object RainyWindowOptic { + public val RainyWindow: CompositeShader = + MirageShader.composite("rainyWindow", ::RainyWindowParams) { xy -> + val res = mirageResolution + val amount = clamp(rainAmount, 0f, 1f) + + val uv0 = (xy - 0.5f * res) / max(res.y, 1f) + // uv.y = -uv.y (F4 write-swizzle workaround: rebuild flipped) + val uv1 = float2(uv0.x, -uv0.y) + val scale = max(dropScale, 0.2f) + val uv = uv1 * scale + + val timeWrapped = mod(mirageTime, 120f) + val t = timeWrapped * 0.2f + + val uvNorm = xy / max(res, float2(1f, 1f)) + + // Bilinear wipe-mask read: four clamped taps (kept un-merged by SampleTexture position-dependence). + val maskUV = clamp(xy / max(res, float2(1f, 1f)), 0f, 1f) * maskSize + val mlo = float2(0.5f, 0.5f) + val mhi = float2(maskSize - 0.5f, maskSize - 0.5f) + val base = floor(maskUV - 0.5f) + 0.5f + val f = fract(maskUV - 0.5f) + val w00 = clamp(wipeMask.eval(clamp(base, mlo, mhi)).r, 0f, 1f) + val w10 = clamp(wipeMask.eval(clamp(base + float2(1f, 0f), mlo, mhi)).r, 0f, 1f) + val w01 = clamp(wipeMask.eval(clamp(base + float2(0f, 1f), mlo, mhi)).r, 0f, 1f) + val w11 = clamp(wipeMask.eval(clamp(base + float2(1f, 1f), mlo, mhi)).r, 0f, 1f) + val wiped = mix(mix(w00, w10, f.x), mix(w01, w11, f.x), f.y) + + val maxBlur = mix(3f, 6f, amount) + val minBlur = float1(2f) + val staticDrops = smoothstep(-0.5f, 1f, amount) * 2f + val layer1 = smoothstep(0.25f, 0.75f, amount) + val layer2 = smoothstep(0f, 0.5f, amount) + + val c = drops(uv, t, staticDrops, layer1, layer2) + + val e = float2(0.001f, 0f) + val cx = drops(uv + e, t, staticDrops, layer1, layer2).x + val cy = drops(uv + e.yx, t, staticDrops, layer1, layer2).x + val n0 = float2(cx - c.x, cy - c.x) + // n.y = -n.y + val n = float2(n0.x, -n0.y) + + val focus0 = mix(maxBlur - c.y, minBlur, smoothstep(0.1f, 0.2f, c.x)) + val focus = mix(focus0, minBlur, clamp(wiped, 0f, 1f)) + var foggy by local(clamp((focus - minBlur) / max(maxBlur - minBlur, 1e-3f), 0f, 1f)) + foggy = foggy * clamp(fogAmount, 0f, 1f) + + val nPx0 = n * res + val nLen = length(nPx0) + val nPx = nPx0 * (min(nLen, 24f) / max(nLen, 1e-4f)) + val tap = clamp((uvNorm * res) + nPx, float2(0.5f, 0.5f), res - 0.5f) + val sharpBg = sampleContent(tap) + + val st = max(blurRadius, 0f) * foggy + val st2 = st * 2f + + var fogged by local(sharpBg) + If(foggy greaterThan 0.01f) { + val blur0 = sharpBg + + sampleContent(clamp(tap + float2(-st2, -st2), float2(0.5f, 0.5f), res - 0.5f)) + + sampleContent(clamp(tap + float2(st2, -st2), float2(0.5f, 0.5f), res - 0.5f)) + + sampleContent(clamp(tap + float2(-st2, st2), float2(0.5f, 0.5f), res - 0.5f)) + + sampleContent(clamp(tap + float2(st2, st2), float2(0.5f, 0.5f), res - 0.5f)) + val blur = blur0 * half(1f / 5f) + fogged = mix(sharpBg, blur, half(foggy)) + } + val haze = half3(0.86f, 0.90f, 0.94f) + // fogged.rgb = mix(fogged.rgb, haze, foggy * hazeStrength) + val foggedRgb = mix(fogged.rgb, haze, half(foggy) * half(clamp(hazeStrength, 0f, 1f))) + val foggedFinal = half4(foggedRgb, fogged.a) + + val photo = sampleContent(clamp(xy, float2(0.5f, 0.5f), res - 0.5f)) + + mix(photo, foggedFinal, half(clamp(introProgress, 0f, 1f))) + } +} + +/** Shader uniforms (property name == uniform id). [wipeMask] is the finger-wipe fog mask. */ +@ExperimentalMirage +public class RainyWindowParams : MirageParams() { + public val wipeMask: UTexture by texture(default = null, tileMode = TileMode.Clamp) + public val maskSize: UFloat by uniform(256f) + public val introProgress: UFloat by uniform(1f) + public val rainAmount: UFloat by uniform(0.6f) + public val blurRadius: UFloat by uniform(9f) + public val fogAmount: UFloat by uniform(1f) + public val hazeStrength: UFloat by uniform(0.55f) + public val dropScale: UFloat by uniform(1f) +} + +// --- The six "Heartfelt" helper functions, traced once and spliced ahead of main. Registration order +// is first-call order, which is dependency-first (N13/N/Saw before their callers), so the emitted GLSL +// declares every helper before it is used. --- + +/** `float3 N13(float p)` — a 3-lane hash. Single-expression (no locals). */ +private fun n13(p: Float1): Float3 { + val node = defineHelper( + name = "N13", + params = listOf("p" to ShaderType.Float1), + returnType = ShaderType.Float3, + args = listOf(p.e), + ) { + val pArg = Float1(Argument("p", ShaderType.Float1)) + val p3a = fract(float3(pArg, pArg, pArg) * float3(0.1031f, 0.11369f, 0.13787f)) + val p3 = p3a + dot(p3a, p3a.yzx + 19.19f) + fract( + float3( + (p3.x + p3.y) * p3.z, + (p3.x + p3.z) * p3.y, + (p3.y + p3.z) * p3.x, + ), + ).e + } + return Float3(node) +} + +/** `float N(float t)`. */ +private fun nHash(t: Float1): Float1 { + val node = defineHelper( + name = "N", + params = listOf("t" to ShaderType.Float1), + returnType = ShaderType.Float1, + args = listOf(t.e), + ) { + val tArg = Float1(Argument("t", ShaderType.Float1)) + fract(sin(tArg * 12345.564f) * 7658.76f).e + } + return Float1(node) +} + +/** `float Saw(float b, float t)`. */ +private fun saw(b: Float1, t: Float1): Float1 { + val node = defineHelper( + name = "Saw", + params = listOf("b" to ShaderType.Float1, "t" to ShaderType.Float1), + returnType = ShaderType.Float1, + args = listOf(b.e, t.e), + ) { + val bArg = Float1(Argument("b", ShaderType.Float1)) + val tArg = Float1(Argument("t", ShaderType.Float1)) + (smoothstep(float1(0f), bArg, tArg) * smoothstep(float1(1f), bArg, tArg)).e + } + return Float1(node) +} + +/** `float2 DropLayer2(float2 uv, float t)` — the running-drop layer (statement body). */ +private fun dropLayer2(uvIn: Float2, tIn: Float1): Float2 { + val node = defineHelper( + name = "DropLayer2", + params = listOf("uv" to ShaderType.Float2, "t" to ShaderType.Float1), + returnType = ShaderType.Float2, + args = listOf(uvIn.e, tIn.e), + ) { + val uvArg = Float2(Argument("uv", ShaderType.Float2)) + val tArg = Float1(Argument("t", ShaderType.Float1)) + + val bigUV = uvArg // float2 UV = uv; + var uv by local(uvArg) + uv = float2(uv.x, uv.y + tArg * 0.75f) // uv.y += t * 0.75 + + val a = float2(6f, 1f) + val grid = a * 2f + var id by local(floor(uv * grid)) + val colShift = nHash(id.x) + uv = float2(uv.x, uv.y + colShift) // uv.y += colShift + id = floor(uv * grid) + val n = n13(id.x * 35.2f + id.y * 2376.1f) + val stv = fract(uv * grid) - float2(0.5f, 0f) + var x by local(n.x - 0.5f) + var y by local(bigUV.y * 20f) + val wiggle = sin(y + sin(y)) + x = x + wiggle * (0.5f - abs(x)) * (n.z - 0.5f) + x = x * 0.7f + val ti = fract(tArg + n.z) + y = (saw(float1(0.85f), ti) - 0.5f) * 0.9f + 0.5f + // Freeze every intermediate that reads the mutable `x`/`y` here: a plain `val` captures VarRef(y), + // which would re-read `y`'s *current* value at each inlined use — but `y` is reassigned below, so + // these must snapshot now, exactly as the original's `float`/`float2` locals do. + val p by local(float2(x, y)) + val d by local(length((stv - p) * a.yx)) + val mainDrop by local(smoothstep(0.4f, 0f, d)) + val r by local(sqrt(smoothstep(float1(1f), y, stv.y))) + val cd by local(abs(stv.x - x)) + var trail by local(smoothstep(0.23f * r, 0.15f * r * r, cd)) + val trailFront by local(smoothstep(-0.02f, 0.02f, stv.y - y)) + trail = trail * trailFront * r * r + y = bigUV.y + val trail2 = smoothstep(0.2f * r, float1(0f), cd) + var droplets by local( + max(float1(0f), (sin(y * (1f - y) * 120f) - stv.y)) * trail2 * trailFront * n.z, + ) + y = fract(y * 10f) + (stv.y - 0.5f) + val dd = length(stv - float2(x, y)) + droplets = smoothstep(0.3f, 0f, dd) + val m = mainDrop + droplets * r * trailFront + float2(m, trail).e + } + return Float2(node) +} + +/** `float StaticDrops(float2 uv, float t)` — the sparse static-drop layer (statement body). */ +private fun staticDrops(uvIn: Float2, tIn: Float1): Float1 { + val node = defineHelper( + name = "StaticDrops", + params = listOf("uv" to ShaderType.Float2, "t" to ShaderType.Float1), + returnType = ShaderType.Float1, + args = listOf(uvIn.e, tIn.e), + ) { + val uvArg = Float2(Argument("uv", ShaderType.Float2)) + val tArg = Float1(Argument("t", ShaderType.Float1)) + + var uv by local(uvArg * 40f) + // Freeze `id` before `uv` is reassigned below (a plain val would re-read the mutated `uv`). + val id by local(floor(uv)) + uv = fract(uv) - 0.5f + val n = n13(id.x * 107.45f + id.y * 3543.654f) + val p = (n.xy - 0.5f) * 0.7f + val d = length(uv - p) + val fade = saw(float1(0.025f), fract(tArg + n.z)) + val c = smoothstep(0.3f, 0f, d) * fract(n.z * 10f) * fade + c.e + } + return Float1(node) +} + +/** `float2 Drops(float2 uv, float t, float l0, float l1, float l2)` (statement body). */ +private fun drops(uvIn: Float2, tIn: Float1, l0In: Float1, l1In: Float1, l2In: Float1): Float2 { + val node = defineHelper( + name = "Drops", + params = listOf( + "uv" to ShaderType.Float2, + "t" to ShaderType.Float1, + "l0" to ShaderType.Float1, + "l1" to ShaderType.Float1, + "l2" to ShaderType.Float1, + ), + returnType = ShaderType.Float2, + args = listOf(uvIn.e, tIn.e, l0In.e, l1In.e, l2In.e), + ) { + val uvArg = Float2(Argument("uv", ShaderType.Float2)) + val tArg = Float1(Argument("t", ShaderType.Float1)) + val l0 = Float1(Argument("l0", ShaderType.Float1)) + val l1 = Float1(Argument("l1", ShaderType.Float1)) + val l2 = Float1(Argument("l2", ShaderType.Float1)) + + val s = staticDrops(uvArg, tArg) * l0 + val m1 = dropLayer2(uvArg, tArg) * l1 + val m2 = dropLayer2(uvArg * 1.85f, tArg) * l2 + var c by local(s + m1.x + m2.x) + c = smoothstep(0.3f, 1f, c) + float2(c, max(m1.y * l0, m2.y * l1)).e + } + return Float2(node) +} diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/Branch.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/Branch.kt new file mode 100644 index 00000000..ab99a9a1 --- /dev/null +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/Branch.kt @@ -0,0 +1,193 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage + +/** + * A value-producing branch, the DSL's if/else-*expression* (Kotlin has none to overload, and the + * statement-form [If] can't return a value). Each arm is a block that may record several statements and + * ends in a value; both arms must produce the same type. It lowers to a temp declared once and written by + * every arm, wired through [IfBlock] + [IfBlock.elseBody]: + * + * ```glsl + * _vN; + * if (cond) { ; _vN = ; } + * else { ; _vN = ; } + * ``` + * + * `branch(...) { ... }` alone is a [BranchBuilder], **not** the value type — you cannot use it as a value + * until you close it with [Else]. That is what makes the else mandatory: a branch with no else has no + * defined value on the false path. + */ +@ExperimentalMirage +public fun branch(condition: UBool, ifTrue: () -> T): BranchBuilder = + BranchBuilder(condition.e, ifTrue) + +/** The half-open [branch]; only [Else] turns it into the arms' value type. */ +@ExperimentalMirage +public class BranchBuilder internal constructor( + internal val condition: Expression, + internal val ifTrue: () -> T, +) + +/** + * Closes a [branch] with its false arm and yields the arms' common value type. Traces both arms, declares + * the temp at the arms' [ShaderType], and emits the `if/else` that writes it on both paths. A type + * mismatch between the arms fails with [MirageDiagnosticCode.BRANCH_TYPE_MISMATCH]. + */ +@Suppress("UNCHECKED_CAST") +@ExperimentalMirage +public infix fun BranchBuilder.Else(ifFalse: () -> T): T { + val trace = activeTrace() + val temp = trace.freshName() + + val (trueStmts, trueValue) = trace.traceHelper(ifTrue) + val (falseStmts, falseValue) = trace.traceHelper(ifFalse) + val type = armType(trueValue.e, falseValue.e) + + trace.statements += DeclareLocal(temp, type) + trace.statements += IfBlock( + condition, + trueStmts + Reassign(temp, trueValue.e), + falseStmts + Reassign(temp, falseValue.e), + ) + return wrapNode(type, VarRef(temp, type)) as T +} + +/** A DSL scope for [When], so a nested `When`'s [WhenScope.then] can't leak into an outer scope. */ +@DslMarker +@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) +@ExperimentalMirage +public annotation class MirageWhenDsl + +/** + * A value-producing multi-way branch — the DSL's when-*expression*. Each ` then { ... }` case is a + * value-producing block; the first case whose condition is true wins (top to bottom), and a **mandatory** + * [WhenScope.otherwise] supplies the fallthrough value. It lowers to a first-match chain of nested + * [IfBlock]s (each next case in the previous [IfBlock.elseBody]), so it emits the same `if / else if / + * else` shape a hand-written when would: + * + * ```glsl + * _vN; + * if (c1) { _vN = ; } + * else { if (c2) { _vN = ; } else { _vN = ; } } + * ``` + * + * The `otherwise` is required at **runtime** (trace time), not compile time: forgetting it returns a value + * not produced by [WhenScope.otherwise], detected by an identity check and reported as + * [MirageDiagnosticCode.WHEN_WITHOUT_OTHERWISE]. Arm type mismatches raise + * [MirageDiagnosticCode.BRANCH_TYPE_MISMATCH]. There is deliberately no `elseIf` chain (a formatter that + * wraps its infix `Else` onto a new line breaks the parse) and no implicit-else form (its result type + * would collapse to `Any`) — the two shapes are [branch]…[Else] (2-way) and this `When` (n-way). + */ +@ExperimentalMirage +public fun When(block: (@MirageWhenDsl WhenScope).() -> T): T { + val scope = WhenScope() + val result = scope.block() + // The block's final expression must be the object otherwise() returned. A block that forgets otherwise + // ends in some other value (a stray arm), so identity differs — caught here as a trace diagnostic. + if (result !== scope.otherwiseSentinel) { + throw MirageDiagnosticException( + MirageDiagnosticCode.WHEN_WITHOUT_OTHERWISE, + "a When block must end in otherwise { ... }", + "add a final otherwise { ... } arm supplying the fallthrough value", + ) + } + return scope.lower() +} + +/** The receiver of a [When] block: records `then` cases and closes them with the mandatory `otherwise`. */ +@MirageWhenDsl +@ExperimentalMirage +public class WhenScope internal constructor() { + private val cases = + mutableListOf, Expression, ShaderType>>>() + private var otherwiseCase: Triple, Expression, ShaderType>? = null + + /** + * The object [otherwise] returns and [When] identity-checks the block's result against. Built lazily as + * the fallthrough arm is traced (its [ShaderType] is only known then), so it is a genuine [T] — no + * unchecked cast of a wrong-typed sentinel. + */ + internal var otherwiseSentinel: T? = null + private set + + /** Records a ` then { value }` case; first true case wins in declaration order. Traced eagerly. */ + public infix fun UBool.then(value: () -> T) { + cases += e to traceArm(value) + } + + /** The mandatory fallthrough arm; its return object is the sentinel [When] checks for. */ + @Suppress("UNCHECKED_CAST") + public fun otherwise(value: () -> T): T { + val arm = traceArm(value) + otherwiseCase = arm + val sentinel = wrapNode(arm.third, Argument("__when_otherwise__", arm.third)) as T + otherwiseSentinel = sentinel + return sentinel + } + + /** Traces one arm's block into (its statements, its value node, its type), the [BranchBuilder.Else] split. */ + private fun traceArm(arm: () -> T): Triple, Expression, ShaderType> { + val (stmts, value) = activeTrace().traceHelper(arm) + return Triple(stmts, value.e, value.e.type) + } + + /** Lowers the recorded cases + otherwise into the nested-[IfBlock] first-match chain; see [When]. */ + @Suppress("UNCHECKED_CAST") + internal fun lower(): T { + val trace = activeTrace() + val temp = trace.freshName() + val (otherStmts, otherValue, type) = otherwiseCase + ?: error("When lowered without an otherwise arm") + + for ((_, arm) in cases) { + if (arm.third != type) { + throw MirageDiagnosticException( + MirageDiagnosticCode.BRANCH_TYPE_MISMATCH, + "branch/When arms produced different types: ${arm.third.name} vs ${type.name}", + "make every arm return the same value type", + ) + } + } + + // Fold bottom-up: the innermost else is the otherwise arm, each case wraps the chain built so far. + var elseBody: List = otherStmts + Reassign(temp, otherValue) + for ((cond, arm) in cases.asReversed()) { + elseBody = listOf(IfBlock(cond, arm.first + Reassign(temp, arm.second), elseBody)) + } + + trace.statements += DeclareLocal(temp, type) + // The outermost IfBlock, or (with no cases) the bare otherwise write. + trace.statements += elseBody + return wrapNode(type, VarRef(temp, type)) as T + } +} + +/** The common [ShaderType] of two arm values, or [MirageDiagnosticCode.BRANCH_TYPE_MISMATCH] if they differ. */ +private fun armType(a: Expression, b: Expression): ShaderType { + if (a.type != b.type) { + throw MirageDiagnosticException( + MirageDiagnosticCode.BRANCH_TYPE_MISMATCH, + "branch/When arms produced different types: ${a.type.name} vs ${b.type.name}", + "make every arm return the same value type", + ) + } + return a.type +} diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/Expression.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/Expression.kt new file mode 100644 index 00000000..918cbe25 --- /dev/null +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/Expression.kt @@ -0,0 +1,236 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage + +/** + * A shader value's dialect-neutral type. Carries precision as an attribute (not a separate node kind) + * so `RuntimeEffectEmitter` prints `half`/`float` while a future GLSL ES 3.0 emitter prints + * `mediump`/`highp` from the same [Expression] tree. + * + * Public (though `@ExperimentalMirage`, so it stays out of the committed ABI dump) only because the + * body-lambda value types ([Float1] etc.) expose their backing node via a public `.e: Expression`, + * and that node's [type] must be reachable through it. Kotlin interface members cannot be `internal`, + * so once `.e` is public its whole type graph is too — see [Float1]'s KDoc. + */ +@ExperimentalMirage +public enum class ShaderType(internal val components: Int, internal val isHalf: Boolean) { + Float1(1, false), + Float2(2, false), + Float3(3, false), + Float4(4, false), + Half1(1, true), + Half3(3, true), + Half4(4, true), + Bool(1, false), +} + +/** + * A node in the traced expression tree for a Colorize/Generate/Composite kernel body. Plain classes, + * not value classes: a polymorphic AST boxes on every use as the [Expression] supertype regardless, so + * a value-class wrapper buys nothing here. + * + * Public (but `@ExperimentalMirage`) so the value types can carry it through a public `.e`; every + * concrete node stays `internal`, so the only thing a caller can do with an [Expression] is hand it + * back to an intrinsic — the tree shape is not part of the surface. + */ +@ExperimentalMirage +public sealed interface Expression { + public val type: ShaderType +} + +/** A literal scalar constant, e.g. `0.5`, `SMOOTH_EDGE_PX`'s value inlined at each use. */ +internal data class Literal(val value: Float, override val type: ShaderType = ShaderType.Float1) : + Expression + +/** A reference to a [com.skydoves.cloudy.MirageParams] uniform handle, by declaration slot. */ +internal data class UniformRef(val slot: Int, override val type: ShaderType) : Expression + +/** + * A reference to a standard, name-gated uniform the compiler declares on demand (`mirageTime` / + * `mirageResolution` / `mirageDensity`) — see MirageCompiler.kt's STD_* constants. Not a [UniformRef] + * because it has no [com.skydoves.cloudy.MirageParams] slot; referencing this node is itself what + * makes the compiler emit the declaration, exactly as it already does for the string kernels' textual + * reference scan. + */ +internal data class StandardUniform(val name: String, override val type: ShaderType) : Expression + +/** A kernel argument: `xy` (Composite/Generate) or `src` (Colorize). */ +internal data class Argument(val name: String, override val type: ShaderType) : Expression + +/** A binary operator node (`+ - * /`), GLSL scalar-broadcast semantics. */ +internal data class Binary( + val operator: String, + val left: Expression, + val right: Expression, + override val type: ShaderType, +) : Expression + +/** A unary operator node (`-x`). */ +internal data class Unary( + val operator: String, + val operand: Expression, + override val type: ShaderType, +) : Expression + +/** A comparison node (`> < >= <= == !=`), always [ShaderType.Bool]. */ +internal data class Comparison(val operator: String, val left: Expression, val right: Expression) : + Expression { + override val type: ShaderType get() = ShaderType.Bool +} + +/** An intrinsic, type-constructor, or user-defined-function call, e.g. `mix(a, b, t)`, `half4(r, g, b, a)`. */ +internal data class Call( + val functionName: String, + val args: List, + override val type: ShaderType, +) : Expression + +/** A swizzle read, e.g. `.rgb`, `.a`, `.x`. */ +internal data class Swizzle( + val base: Expression, + val components: String, + override val type: ShaderType, +) : Expression + +/** + * A ternary select, e.g. `p.x >= 0.0 ? 1.0 : -1.0`. Kotlin has no ternary operator to overload, so the + * eDSL surface is the [select] function; this node is what it builds. + */ +internal data class Select( + val condition: Expression, + val ifTrue: Expression, + val ifFalse: Expression, + override val type: ShaderType, +) : Expression + +/** + * A `content.eval(coord)` sample. A dedicated node rather than a [Call] because it is the one construct + * whose spelling actually differs across dialects — AGSL/SkSL both write `.eval()`, but a future GLSL + * ES 3.0 emitter would lower this to a `sampleContent()` helper over a `sampler2D`/`texture()`. AGSL/ + * SkSL emit the same text either way, so [RuntimeEffectEmitter] does not yet need to branch on it — the + * node exists so a GLSL emitter can, without touching callers. + */ +internal data class SampleContent(val coord: Expression) : Expression { + override val type: ShaderType get() = ShaderType.Half4 +} + +/** + * A `.eval(coord)` sample of an arbitrary `uniform shader` texture child (RainyWindow's + * `wipeMask`), parallel to [SampleContent] but over a named [UniformRef] rather than the content + * sampler. Position-dependent for the same reason [SampleContent] is: each tap must stay where the + * author wrote it (the four bilinear taps of a mask read must not be CSE-merged), so [hoist] treats it + * as un-liftable. + */ +internal data class SampleTexture(val texture: Expression, val coord: Expression) : Expression { + override val type: ShaderType get() = ShaderType.Half4 +} + +/** A reference to a local variable bound by a preceding [Assign]/[Reassign] statement. */ +internal data class VarRef(val name: String, override val type: ShaderType) : Expression + +/** A statement in a traced kernel body: a binding, a reassignment, an early exit, or a guarded block. */ +internal sealed interface Statement + +/** `val = ` — a named intermediate, emitted as a local declaration before it is read. */ +internal data class Assign(val name: String, val value: Expression) : Statement + +/** + * ` ;` — an uninitialized local declaration. The one shape [branch]/[When] need: a temp + * declared once at the branch site, then written by a [Reassign] in *every* arm (each arm must assign it, + * so it is always defined when read). Distinct from [Assign], which both declares and initializes. + */ +internal data class DeclareLocal(val name: String, val declaredType: ShaderType) : Statement + +/** + * ` = ;` — updates a var previously introduced by [Assign] (e.g. Specular's mutable + * `pixel`, which the `content.eval` fallback and the highlight block both write). The [VarRef] used to + * build [value] and the reassigned [name] refer to the same local; the emitter does not re-declare it. + */ +internal data class Reassign(val name: String, val value: Expression) : Statement + +/** + * `if () return ;` — the one control-flow shape Foil's early-out needs. Not a general + * if/else; that is P3+ scope if a kernel needs it. + */ +internal data class EarlyReturn(val condition: Expression, val value: Expression) : Statement + +/** + * `if () { }` with no `return` — Specular's highlight block, which conditionally + * computes and folds into `pixel` via a [Reassign] inside [body]. Distinct from [EarlyReturn]: this one + * doesn't exit the kernel, its statements just don't run when the guard is false. + */ +internal data class IfBlock( + val condition: Expression, + val body: List, + val elseBody: List = emptyList(), +) : Statement + +/** + * An ES2-safe bounded loop, emitted as a `for` with a **constant** iteration bound and an inner dynamic + * `break` (KorGE's `FOR_0_UNTIL_FIXED_BREAK` pattern): + * + * ```glsl + * for (int _i = 0; _i < ; _i++) { + * float = float(_i); + * if ( >= ) break; // the real (possibly dynamic) upper bound + * + * } + * ``` + * + * ES2's four loop rules (constant init/compare/increment + an unmodified index) are met **by + * construction** because [maxIterations] is a Kotlin `Int` constant — the dynamic [count] never touches + * the `for` header, only the guarded `break`. The index is a `float` at the surface ([indexName]) but an + * `int` counter underneath, so no `Int1` value type is needed. + */ +internal data class LoopStatement( + val count: Expression, + val maxIterations: Int, + val indexName: String, + val body: List, +) : Statement + +/** + * A user-defined helper function traced alongside the kernel body (e.g. Foil's `foilHash`, RainyWindow's + * `DropLayer2`), spliced into the emitted source ahead of the kernel/main it's used from. + * + * A helper is a real emitted GLSL function, not a macro inline: it has [params] (N of them, each a + * `name: type`), an ordered [body] of statements building explicit locals, and a [returnExpr]. The + * body's statements already carry their own author-given local names, so the kernel-body hoist (CSE) + * does not descend into them — a helper is declared once and called, its internals are its own scope. + * + * A single-expression helper (Foil's `foilHash`) is just the degenerate case: empty [body], the whole + * expression in [returnExpr]. + */ +internal class HelperFunction( + val name: String, + val params: List>, + val returnType: ShaderType, + val body: List, + val returnExpr: Expression, +) + +/** + * The completed trace of one kernel: the ordered [statements] leading up to [returnExpr], plus any + * [helpers] the body called. A Colorize trace typically has no statements (MVP is expression-only); a + * Composite/Generate trace with an early-return has one [EarlyReturn] statement before the final value. + */ +internal class ShaderModule( + val statements: List, + val returnExpr: Expression, + val helpers: List = emptyList(), +) diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/MirageDiagnostics.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/MirageDiagnostics.kt new file mode 100644 index 00000000..42cfe16b --- /dev/null +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/MirageDiagnostics.kt @@ -0,0 +1,54 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage + +/** + * Stable, machine-readable mirage diagnostic codes — stable across versions so tests and tooling assert + * on the code, never on the (freely reworded) message. + * + * The full set is declared up front even though this scope only throws four of them: the remaining + * codes are the stable contract the later authoring features (`shaderFunction`, `branch`/`When`) will + * raise, and pinning them now keeps their code values from shifting once those features land. + */ +@ExperimentalMirage +public enum class MirageDiagnosticCode { + INTRINSIC_OUTSIDE_BODY, // statement-recording intrinsic ran with no open trace + NESTED_TRACE, // a shader body started while another was tracing + RESERVED_IDENTIFIER, // a user helper name shadows a builtin/preamble name + FUNCTION_CYCLE, // shaderFunction call cycle (not yet raised) + FUNCTION_RETURN_TYPE_MISMATCH, // declared vs traced return type (not yet raised) + BRANCH_TYPE_MISMATCH, // branch/When arms produced different types (not yet raised) + WHEN_WITHOUT_OTHERWISE, // When returned a value not produced by otherwise {} (not yet raised) + FORBIDDEN_TOKEN, // raw-string body lint (fwidth / # / sk_FragCoord) + CONTENT_IN_COLORIZE, // Colorize/Generate referenced content +} + +/** + * The single failure type for every mirage authoring/compile diagnostic. Carries a stable [code] so a + * test or tool can branch on it, plus a human-readable [rawMessage] and a [hint] on how to fix it. + * [Throwable.message] is the formatted `"$rawMessage ($code) — $hint"` — not [rawMessage] alone — so a + * stack trace or `toString()` shows the full diagnostic, not just its first clause. + * + * `internal`: the [code] enum is the only part of the contract callers assert on, and it is public + * behind the marker. + */ +internal class MirageDiagnosticException( + val code: MirageDiagnosticCode, + val rawMessage: String, + val hint: String, +) : IllegalArgumentException("$rawMessage ($code) — $hint") diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/MirageReservedNames.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/MirageReservedNames.kt new file mode 100644 index 00000000..f43ac8be --- /dev/null +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/MirageReservedNames.kt @@ -0,0 +1,53 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.internal.PREAMBLE_HELPER_NAMES + +/** + * The identifiers a future user-authored helper (`shaderFunction`) must not reuse — reusing one would + * shadow a builtin/preamble/keyword and silently miscompile on the GPU. + * + * Nothing calls this yet: the only current name sources ([TraceContext.freshName] `_v`, hoist `_t`) are + * `_`-prefixed and cannot collide with a user name, so the guard has no live use until `shaderFunction` + * lands. It is built now as that feature's prerequisite, and its [BUILTIN_FUNCTIONS] half is fenced by + * the "every emitted builtin is reserved" test so the list can't drift from what `ShaderValues` emits. + */ +internal object MirageReservedNames { + + /** + * The GLSL intrinsic/type-constructor names `ShaderValues` prints via `Call("name", ...)`, plus + * `foilHash` — a trace-registered helper, not a GLSL builtin or a [PREAMBLE_HELPER_NAMES] preamble + * constant, so it has no other single source to draw from. Kept in sync with the emitter by + * [MirageReservedNamesTest]'s divergence gate. Preamble helpers (`boxRoundedSDF`, ...) are *also* + * emitted this way but are reserved via [PREAMBLE_HELPER_NAMES] instead — listing them here too would + * be a second source for the same name. + */ + val BUILTIN_FUNCTIONS: Set = setOf( + "abs", "clamp", "cos", "dot", "exp", "floor", "fract", "half", "half3", "half4", + "float2", "float3", "float4", "length", "max", "min", "mirage_luma", "mix", "mod", + "normalize", "pow", "sin", "smoothstep", "sqrt", "step", "foilHash", + ) + + /** The GLSL/SkSL keywords a helper name must not collide with. */ + val GLSL_KEYWORDS: Set = setOf( + "if", "else", "for", "while", "do", "return", "break", "continue", "discard", + "in", "out", "inout", "const", "uniform", "layout", "main", "kernel", + "true", "false", "half", "float", "int", "bool", "void", + ) + + val RESERVED: Set = BUILTIN_FUNCTIONS + PREAMBLE_HELPER_NAMES + GLSL_KEYWORDS +} diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/RuntimeEffectEmitter.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/RuntimeEffectEmitter.kt new file mode 100644 index 00000000..d119f646 --- /dev/null +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/RuntimeEffectEmitter.kt @@ -0,0 +1,358 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.cloudy.edsl + +/** + * Prints a [ShaderModule] to a Colorize `kernel(float2 p, half4 src)` body. One printer for both AGSL + * and SkSL: AGSL is Skia's public (ES2-restricted) runtime-effect profile, so any expression this + * emitter can produce compiles under both — there is no dialect branch to make here. + * + * [uniformNames] maps a declaration slot to its uniform identifier, so [UniformRef] prints the same + * name the hand-written kernels read (`shadow`, `highlight`, `amount`, ...). + */ +internal fun emitColorizeKernel(module: ShaderModule, uniformNames: List): String = + hoist(module).let { hoisted -> + emitHelpers(hoisted.helpers, uniformNames) + + "half4 kernel(float2 p, half4 src) {\n" + + emitBody(hoisted, uniformNames, indent = " ") + + "}" + } + +/** + * Prints a [ShaderModule] to a Composite/Generate `main(float2 xy)` body — the shape both categories + * splice into the preamble verbatim (MirageCompiler.kt's `assemble`), content sampling included in the + * traced expressions rather than synthesized by a wrapper (unlike Colorize). + */ +internal fun emitCompositeOrGenerateMain(module: ShaderModule, uniformNames: List): String = + hoist(module).let { hoisted -> + emitHelpers(hoisted.helpers, uniformNames) + + "half4 main(float2 xy) {\n" + + emitBody(hoisted, uniformNames, indent = " ") + + "}" + } + +private fun emitBody(module: ShaderModule, uniformNames: List, indent: String): String { + val statements = module.statements.joinToString("") { emitStatement(it, uniformNames, indent) } + return statements + "$indent return ${emit(module.returnExpr, uniformNames)};\n" +} + +/** + * Common-subexpression hoisting. A fully-inlined trace repeats `lensSize * 0.5`, `max(halfDim.x, 1.0)` + * and the like dozens of times, and the nesting depth alone overruns SkSL's parser (`exceeded max + * parse depth`) — so this is a correctness pass, not a cosmetic one. It walks the whole forest + * (return value + every statement, into nested `if` bodies), counts each **structurally equal** + * subtree, and lifts every one that appears twice or more into a `_tN` local declared once at the top, + * replacing its occurrences with a [VarRef]. + * + * Deterministic by construction (same tree in → same names out), which the Chromatic five-look test + * needs: it asserts every look emits byte-identical source, and each look re-runs this pass. + * + * A subtree is liftable only when it is position-independent — it must contain no [SampleContent] (a + * `content.eval` tap stays exactly where the author wrote it; identical taps are never merged) and no + * [VarRef] (a mutable `local`, which is only valid after its own assignment, not at the top). Trivial + * leaves ([Literal] / [Argument] / [UniformRef] / [StandardUniform] / [VarRef]) are never worth a name. + * + * Lifting a subtree to the top can move it *above* an [EarlyReturn] guard it originally sat behind, so + * it is now computed even for pixels the guard would have skipped. That is safe here because the + * intrinsics are total (a bevel `normalize`/`pow` on an out-of-lens pixel just yields a discarded + * value — the guard still returns its own result), and the raster-parity tests exercise the guarded + * out-of-lens region. A future kernel whose guard protects a genuinely undefined operation would need + * that guard kept as real control flow rather than folded into a lifted expression. + */ +private fun hoist(module: ShaderModule): ShaderModule { + val roots = buildList { + add(module.returnExpr) + fun collect(statements: List) { + for (s in statements) { + when (s) { + is Assign -> add(s.value) + + is DeclareLocal -> Unit + + is Reassign -> add(s.value) + + is EarlyReturn -> { + add(s.condition) + add(s.value) + } + + is IfBlock -> { + add(s.condition) + collect(s.body) + collect(s.elseBody) + } + + is LoopStatement -> { + add(s.count) + collect(s.body) + } + } + } + } + collect(module.statements) + } + + val counts = mutableMapOf() + for (root in roots) countSubtrees(root, counts) + + // Liftable subtrees, ordered smallest-first so a lifted subtree's own lifted children are declared + // ahead of it (a smaller subtree strictly contained in a larger one has a lower node count). + val order = mutableMapOf() // first-encounter index, the deterministic tie-break + for (root in roots) recordOrder(root, order) + val lifted = counts.entries + .filter { it.value >= 2 && isLiftable(it.key) } + .map { it.key } + .sortedWith(compareBy({ nodeCount(it) }, { order[it] ?: Int.MAX_VALUE })) + + if (lifted.isEmpty()) return module + + val names = lifted.mapIndexed { i, expr -> expr to "_t$i" }.toMap() + + // Each lifted subtree's initializer substitutes its *nested* lifted subtrees (but not itself). + val hoistAssigns = lifted.map { expr -> + Assign(names.getValue(expr), substitute(expr, names, self = expr)) + } + + val newStatements = hoistAssigns + module.statements.map { substituteStatement(it, names) } + return ShaderModule( + newStatements, + substitute(module.returnExpr, names, self = null), + module.helpers, + ) +} + +/** Counts every structurally distinct liftable subtree; recurses through children regardless. */ +private fun countSubtrees(node: Expression, counts: MutableMap) { + if (isLiftable(node)) counts[node] = (counts[node] ?: 0) + 1 + for (child in children(node)) countSubtrees(child, counts) +} + +private fun recordOrder(node: Expression, order: MutableMap) { + if (isLiftable(node) && node !in order) order[node] = order.size + for (child in children(node)) recordOrder(child, order) +} + +/** Replaces each key subtree with its `_tN` [VarRef], except [self] (a lifted subtree's own body). */ +private fun substitute( + node: Expression, + names: Map, + self: Expression?, +): Expression { + if (node !== self) names[node]?.let { return VarRef(it, node.type) } + return withChildren(node) { substitute(it, names, self) } +} + +private fun substituteStatement(node: Statement, names: Map): Statement = + when (node) { + is Assign -> Assign(node.name, substitute(node.value, names, self = null)) + + is DeclareLocal -> node + + is Reassign -> Reassign(node.name, substitute(node.value, names, self = null)) + + is EarlyReturn -> EarlyReturn( + substitute(node.condition, names, self = null), + substitute(node.value, names, self = null), + ) + + is IfBlock -> IfBlock( + substitute(node.condition, names, self = null), + node.body.map { substituteStatement(it, names) }, + node.elseBody.map { substituteStatement(it, names) }, + ) + + is LoopStatement -> LoopStatement( + substitute(node.count, names, self = null), + node.maxIterations, + node.indexName, + node.body.map { substituteStatement(it, names) }, + ) + } + +/** Trivial leaves and position-dependent nodes (`content.eval`, mutable locals) are never lifted. */ +private fun isLiftable(node: Expression): Boolean = when (node) { + is Literal, is Argument, is UniformRef, is StandardUniform, is VarRef -> false + else -> !containsPositionDependent(node) +} + +private fun containsPositionDependent(node: Expression): Boolean = when (node) { + is SampleContent, is SampleTexture, is VarRef -> true + else -> children(node).any { containsPositionDependent(it) } +} + +private fun children(node: Expression): List = when (node) { + is Literal, is Argument, is UniformRef, is StandardUniform, is VarRef -> emptyList() + is Unary -> listOf(node.operand) + is Binary -> listOf(node.left, node.right) + is Comparison -> listOf(node.left, node.right) + is Swizzle -> listOf(node.base) + is SampleContent -> listOf(node.coord) + is SampleTexture -> listOf(node.texture, node.coord) + is Select -> listOf(node.condition, node.ifTrue, node.ifFalse) + is Call -> node.args +} + +private fun nodeCount(node: Expression): Int = 1 + children(node).sumOf { nodeCount(it) } + +/** Rebuilds [node] with each child mapped through [transform], preserving its type. */ +private fun withChildren(node: Expression, transform: (Expression) -> Expression): Expression = + when (node) { + is Literal, is Argument, is UniformRef, is StandardUniform, is VarRef -> node + + is Unary -> node.copy(operand = transform(node.operand)) + + is Binary -> node.copy(left = transform(node.left), right = transform(node.right)) + + is Comparison -> node.copy(left = transform(node.left), right = transform(node.right)) + + is Swizzle -> node.copy(base = transform(node.base)) + + is SampleContent -> node.copy(coord = transform(node.coord)) + + is SampleTexture -> node.copy(texture = transform(node.texture), coord = transform(node.coord)) + + is Select -> node.copy( + condition = transform(node.condition), + ifTrue = transform(node.ifTrue), + ifFalse = transform(node.ifFalse), + ) + + is Call -> node.copy(args = node.args.map(transform)) + } + +private fun emitHelpers(helpers: List, uniformNames: List): String = + helpers.joinToString("") { helper -> + val params = helper.params.joinToString(", ") { (name, type) -> "${typeToken(type)} $name" } + val signature = "${typeToken(helper.returnType)} ${helper.name}($params)" + if (helper.body.isEmpty()) { + "$signature { return ${emit(helper.returnExpr, uniformNames)}; }\n" + } else { + val body = helper.body.joinToString("") { emitStatement(it, uniformNames, " ") } + "$signature {\n" + body + " return ${emit(helper.returnExpr, uniformNames)};\n}\n" + } + } + +private fun emitStatement(node: Statement, uniformNames: List, indent: String): String = + when (node) { + is Assign -> "$indent${typeToken( + node.value.type, + )} ${node.name} = ${emit(node.value, uniformNames)};\n" + + is DeclareLocal -> "$indent${typeToken(node.declaredType)} ${node.name};\n" + + is Reassign -> "$indent${node.name} = ${emit(node.value, uniformNames)};\n" + + is EarlyReturn -> + "$indent" + + "if (${emit(node.condition, uniformNames)}) { return ${emit(node.value, uniformNames)}; }\n" + + is IfBlock -> { + val innerIndent = "$indent " + val body = node.body.joinToString("") { emitStatement(it, uniformNames, innerIndent) } + val head = "$indent" + "if (${emit(node.condition, uniformNames)}) {\n" + body + "$indent}" + if (node.elseBody.isEmpty()) { + "$head\n" + } else { + val elseBody = node.elseBody.joinToString("") { + emitStatement(it, uniformNames, innerIndent) + } + "$head else {\n" + elseBody + "$indent}\n" + } + } + + is LoopStatement -> { + val innerIndent = "$indent " + val body = node.body.joinToString("") { emitStatement(it, uniformNames, innerIndent) } + "$indent" + "for (int _i = 0; _i < ${node.maxIterations}; _i++) {\n" + + "$innerIndent" + "float ${node.indexName} = float(_i);\n" + + "$innerIndent" + "if (${node.indexName} >= ${emit(node.count, uniformNames)}) break;\n" + + body + "$indent}\n" + } + } + +private fun emit(node: Expression, uniformNames: List): String = when (node) { + is Literal -> formatLiteral(node.value) + + is Argument -> node.name + + is UniformRef -> uniformNames[node.slot] + + is StandardUniform -> node.name + + is VarRef -> node.name + + is SampleContent -> "content.eval(${emit(node.coord, uniformNames)})" + + is SampleTexture -> + "${emit(node.texture, uniformNames)}.eval(${emit(node.coord, uniformNames)})" + + is Select -> + "(${emit( + node.condition, + uniformNames, + )} ? ${emit(node.ifTrue, uniformNames)} : ${emit(node.ifFalse, uniformNames)})" + + is Unary -> "${node.operator}${emit(node.operand, uniformNames)}" + + is Comparison -> "(${emit( + node.left, + uniformNames, + )} ${node.operator} ${emit(node.right, uniformNames)})" + + is Binary -> "(${emit( + node.left, + uniformNames, + )} ${node.operator} ${emit(node.right, uniformNames)})" + + is Swizzle -> "${emit(node.base, uniformNames)}.${node.components}" + + is Call -> emitCall(node, uniformNames) +} + +/** + * `mirage_luma` has no AGSL/SkSL preamble helper (adding one would touch the shared preamble for a + * single kernel), so it lowers to the same `dot(rgb, half3(0.2126, 0.7152, 0.0722))` the hand-written + * Duotone kernel spells out. Every other [Call] is a real AGSL/SkSL builtin, constructor, or a + * user-defined [HelperFunction] name and prints verbatim. + */ +private fun emitCall(node: Call, uniformNames: List): String { + val args = node.args.joinToString(", ") { emit(it, uniformNames) } + return if (node.functionName == "mirage_luma") { + "dot($args, half3(0.2126, 0.7152, 0.0722))" + } else { + "${node.functionName}($args)" + } +} + +/** GLSL float literals always carry a decimal point; whole values print e.g. `1.0`, not `1`. */ +private fun formatLiteral(v: Float): String = if (v == + v.toLong().toFloat() +) { + "${v.toLong()}.0" +} else { + v.toString() +} + +private fun typeToken(type: ShaderType): String = when (type) { + ShaderType.Float1 -> "float" + ShaderType.Float2 -> "float2" + ShaderType.Float3 -> "float3" + ShaderType.Float4 -> "float4" + ShaderType.Half1 -> "half" + ShaderType.Half3 -> "half3" + ShaderType.Half4 -> "half4" + ShaderType.Bool -> "bool" +} diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/ShaderFunction.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/ShaderFunction.kt new file mode 100644 index 00000000..04ad11cb --- /dev/null +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/ShaderFunction.kt @@ -0,0 +1,301 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import kotlin.properties.PropertyDelegateProvider +import kotlin.reflect.KProperty + +/** + * A shader value type paired with the [ShaderType] it maps to and the way to wrap a traced node back + * into that value type. A property delegate ([shaderFunction]) has no reified access to its lambda's + * parameter types at runtime, so a call passes these tokens explicitly (`shaderFunction(Float2, Float1) + * { ... }`) — the token both names the emitted GLSL type (`float2`) and rebuilds the `p: Float2` argument + * handle the body traces against. The token objects below are the sole instances; each is a value type's + * companion in spirit, kept separate so the value interfaces stay pure node carriers. + */ +@ExperimentalMirage +public class ShaderValueType internal constructor( + internal val shaderType: ShaderType, + internal val wrap: (Expression) -> T, + internal val unwrap: (T) -> Expression, +) + +@ExperimentalMirage public val Float1Type: ShaderValueType = + ShaderValueType(ShaderType.Float1, { Float1(it) }, { it.e }) + +@ExperimentalMirage public val Float2Type: ShaderValueType = + ShaderValueType(ShaderType.Float2, { Float2(it) }, { it.e }) + +@ExperimentalMirage public val Float3Type: ShaderValueType = + ShaderValueType(ShaderType.Float3, { Float3(it) }, { it.e }) + +@ExperimentalMirage public val Float4Type: ShaderValueType = + ShaderValueType(ShaderType.Float4, { Float4(it) }, { it.e }) + +@ExperimentalMirage public val Half1Type: ShaderValueType = + ShaderValueType(ShaderType.Half1, { Half1(it) }, { it.e }) + +@ExperimentalMirage public val Half3Type: ShaderValueType = + ShaderValueType(ShaderType.Half3, { Half3(it) }, { it.e }) + +@ExperimentalMirage public val Half4Type: ShaderValueType = + ShaderValueType(ShaderType.Half4, { Half4(it) }, { it.e }) + +/** + * The result of `val sdCircle by shaderFunction(...) { ... }`: a callable the body invokes like a plain + * Kotlin function (`sdCircle(xy)`), which registers the traced helper once and returns its [Call] node. + * Read via the [getValue] operator so the `by` delegate hands back the function itself. + */ +@ExperimentalMirage +public class ShaderFunction1 internal constructor(private val call: (A) -> R) { + public operator fun getValue(thisRef: Any?, property: KProperty<*>): (A) -> R = call +} + +@ExperimentalMirage +public class ShaderFunction2 internal constructor(private val call: (A, B) -> R) { + public operator fun getValue(thisRef: Any?, property: KProperty<*>): (A, B) -> R = call +} + +@ExperimentalMirage +public class ShaderFunction3 internal constructor(private val call: (A, B, C) -> R) { + public operator fun getValue(thisRef: Any?, property: KProperty<*>): (A, B, C) -> R = call +} + +/** The 4-parameter arity of [ShaderFunction1]; see its docs for the delegate contract. */ +@ExperimentalMirage +public class ShaderFunction4 internal constructor( + private val call: (A, B, C, D) -> R, +) { + public operator fun getValue(thisRef: Any?, property: KProperty<*>): (A, B, C, D) -> R = call +} + +/** + * The 5-parameter arity of [ShaderFunction1]; see its docs for the delegate contract. This is the widest + * arity, enough to author RainyWindow's `drops(uv, t, l0, l1, l2)`-style helpers. + */ +@ExperimentalMirage +public class ShaderFunction5 internal constructor( + private val call: (A, B, C, D, E) -> R, +) { + public operator fun getValue(thisRef: Any?, property: KProperty<*>): (A, B, C, D, E) -> R = call +} + +/** + * Captures the property name of `val by shaderFunction(...) { ... }` and, on first call, traces + * the body into a user-defined [HelperFunction] named after the property (deduped by [defineHelper]). + * Subsequent calls just emit another [Call] to the already-declared helper. + * + * The captured name is guarded against [MirageReservedNames.RESERVED] at declaration time — a + * `val mix by shaderFunction` would shadow a builtin and silently miscompile, so it fails loudly with + * [MirageDiagnosticCode.RESERVED_IDENTIFIER] instead. The declared return type ([returnType]) is + * cross-checked against the type the traced body actually produced; a mismatch (which Kotlin's generics + * usually reject at compile time, but this catches when a body's value type is widened) raises + * [MirageDiagnosticCode.FUNCTION_RETURN_TYPE_MISMATCH]. + */ +@ExperimentalMirage +public fun shaderFunction( + param: ShaderValueType, + returns: ShaderValueType, + body: (A) -> R, +): PropertyDelegateProvider> = PropertyDelegateProvider { _, property -> + val name = reservedGuard(property.name) + ShaderFunction1 { a -> + val node = defineHelper( + name = name, + params = listOf("p0" to param.shaderType), + returnType = returns.shaderType, + args = listOf(param.unwrap(a)), + ) { + val result = body(param.wrap(Argument("p0", param.shaderType))) + checkReturnType(name, returns.shaderType, returns.unwrap(result)) + } + returns.wrap(node) + } +} + +@ExperimentalMirage +public fun shaderFunction( + param0: ShaderValueType, + param1: ShaderValueType, + returns: ShaderValueType, + body: (A, B) -> R, +): PropertyDelegateProvider> = + PropertyDelegateProvider { _, property -> + val name = reservedGuard(property.name) + ShaderFunction2 { a, b -> + val node = defineHelper( + name = name, + params = listOf("p0" to param0.shaderType, "p1" to param1.shaderType), + returnType = returns.shaderType, + args = listOf(param0.unwrap(a), param1.unwrap(b)), + ) { + val result = body( + param0.wrap(Argument("p0", param0.shaderType)), + param1.wrap(Argument("p1", param1.shaderType)), + ) + checkReturnType(name, returns.shaderType, returns.unwrap(result)) + } + returns.wrap(node) + } + } + +@ExperimentalMirage +public fun shaderFunction( + param0: ShaderValueType, + param1: ShaderValueType, + param2: ShaderValueType, + returns: ShaderValueType, + body: (A, B, C) -> R, +): PropertyDelegateProvider> = + PropertyDelegateProvider { _, property -> + val name = reservedGuard(property.name) + ShaderFunction3 { a, b, c -> + val node = defineHelper( + name = name, + params = listOf( + "p0" to param0.shaderType, + "p1" to param1.shaderType, + "p2" to param2.shaderType, + ), + returnType = returns.shaderType, + args = listOf(param0.unwrap(a), param1.unwrap(b), param2.unwrap(c)), + ) { + val result = body( + param0.wrap(Argument("p0", param0.shaderType)), + param1.wrap(Argument("p1", param1.shaderType)), + param2.wrap(Argument("p2", param2.shaderType)), + ) + checkReturnType(name, returns.shaderType, returns.unwrap(result)) + } + returns.wrap(node) + } + } + +/** The 4-parameter arity of the 1-param [shaderFunction] overload; same tracing contract. */ +@ExperimentalMirage +public fun shaderFunction( + param0: ShaderValueType, + param1: ShaderValueType, + param2: ShaderValueType, + param3: ShaderValueType, + returns: ShaderValueType, + body: (A, B, C, D) -> R, +): PropertyDelegateProvider> = + PropertyDelegateProvider { _, property -> + val name = reservedGuard(property.name) + ShaderFunction4 { a, b, c, d -> + val node = defineHelper( + name = name, + params = listOf( + "p0" to param0.shaderType, + "p1" to param1.shaderType, + "p2" to param2.shaderType, + "p3" to param3.shaderType, + ), + returnType = returns.shaderType, + args = listOf(param0.unwrap(a), param1.unwrap(b), param2.unwrap(c), param3.unwrap(d)), + ) { + val result = body( + param0.wrap(Argument("p0", param0.shaderType)), + param1.wrap(Argument("p1", param1.shaderType)), + param2.wrap(Argument("p2", param2.shaderType)), + param3.wrap(Argument("p3", param3.shaderType)), + ) + checkReturnType(name, returns.shaderType, returns.unwrap(result)) + } + returns.wrap(node) + } + } + +/** + * The 5-parameter arity of the 1-param [shaderFunction] overload; same tracing contract. The widest arity, + * enough to author a RainyWindow `drops(uv, t, l0, l1, l2)`-style helper. + */ +@ExperimentalMirage +public fun shaderFunction( + param0: ShaderValueType, + param1: ShaderValueType, + param2: ShaderValueType, + param3: ShaderValueType, + param4: ShaderValueType, + returns: ShaderValueType, + body: (A, B, C, D, E) -> R, +): PropertyDelegateProvider> = + PropertyDelegateProvider { _, property -> + val name = reservedGuard(property.name) + ShaderFunction5 { a, b, c, d, e -> + val node = defineHelper( + name = name, + params = listOf( + "p0" to param0.shaderType, + "p1" to param1.shaderType, + "p2" to param2.shaderType, + "p3" to param3.shaderType, + "p4" to param4.shaderType, + ), + returnType = returns.shaderType, + args = listOf( + param0.unwrap(a), + param1.unwrap(b), + param2.unwrap(c), + param3.unwrap(d), + param4.unwrap(e), + ), + ) { + val result = body( + param0.wrap(Argument("p0", param0.shaderType)), + param1.wrap(Argument("p1", param1.shaderType)), + param2.wrap(Argument("p2", param2.shaderType)), + param3.wrap(Argument("p3", param3.shaderType)), + param4.wrap(Argument("p4", param4.shaderType)), + ) + checkReturnType(name, returns.shaderType, returns.unwrap(result)) + } + returns.wrap(node) + } + } + +/** Fails with [MirageDiagnosticCode.RESERVED_IDENTIFIER] if [name] shadows a builtin/preamble/keyword. */ +private fun reservedGuard(name: String): String { + if (name in MirageReservedNames.RESERVED) { + throw MirageDiagnosticException( + MirageDiagnosticCode.RESERVED_IDENTIFIER, + "shader function name '$name' shadows a builtin", + "rename it, e.g. '${name}Fn'", + ) + } + return name +} + +/** + * Verifies the type the traced body produced matches the declared return type, then returns the node. + * Kotlin's generics reject most mismatches at compile time; this defends the case where a value type is + * widened (e.g. a body inferred as a supertype) so the emitted `float2 f(...) { return ; }` never + * ships a type error to the GPU compiler. + */ +private fun checkReturnType(name: String, declared: ShaderType, produced: Expression): Expression { + if (produced.type != declared) { + throw MirageDiagnosticException( + MirageDiagnosticCode.FUNCTION_RETURN_TYPE_MISMATCH, + "shader function '$name' declares ${declared.name} but its body produced ${produced.type.name}", + "make the body's final expression a ${declared.name}, or change the declared return type", + ) + } + return produced +} diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/ShaderValues.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/ShaderValues.kt new file mode 100644 index 00000000..d24f2458 --- /dev/null +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/ShaderValues.kt @@ -0,0 +1,829 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.UTexture + +/** + * The typed values a mirage kernel body works with — a `float2` position, a `half4` color, etc. Public + * (behind `@ExperimentalMirage`, so out of the committed ABI dump) because the body lambda handed to + * [com.skydoves.cloudy.MirageShader.colorize] / `composite` / `generate` operates on them directly. + * + * Each is an interface, not a class, for one reason: a [com.skydoves.cloudy.MirageParams] uniform + * handle (`UFloat`, `UColor`, ...) implements the matching value interface, so a handle *is* an + * expression the body can use bare — `mix(shadow.rgb, highlight.rgb, g)` with no `.lift()` step. It is + * **not** `sealed`: a handle lives in a different package (`com.skydoves.cloudy`), and Kotlin's sealed + * rule confines implementors to the declaring package. The backing node is carried through [e]; every + * producer either wraps a node via the companion `invoke` factory ([Float1] etc.) or, for a handle, + * returns a [UniformRef] from its own `e`. + * + * [e] is public because interface members cannot be `internal` in Kotlin — but the only concrete + * [Expression] a caller can obtain is opaque (all node types are `internal`), so exposing it does not + * widen the authoring surface, it only lets a handle satisfy the interface. + * + * The one member every mirage value type carries — its backing [Expression] node. A shared supertype so + * a generic consumer ([branch]/[When]/[otherwise], which take an arbitrary value type `T`) can reach the + * node without a per-type cast. Value-neutral: it adds no authoring surface (the node is opaque), it only + * unifies the `.e` the eight leaves already declare identically. + */ +@ExperimentalMirage +public interface ShaderValue { + public val e: Expression +} + +/** + * Wraps [node] in the leaf value type matching [type] — the inverse of reading `.e`, keyed on the node's + * own [ShaderType]. Lets a generic consumer ([branch]/[When]) hand back a `VarRef(_temp)` as a value of + * the type the arms produced without knowing which leaf it is; the returned leaf implements the arms' + * common interface `T`, so the consumer's `as T` cast holds. [ShaderType.Bool] has no value leaf (a + * [UBool] is only ever built by a comparison), so it is not a valid branch-arm result type. + */ +internal fun wrapNode(type: ShaderType, node: Expression): ShaderValue = when (type) { + ShaderType.Float1 -> Float1(node) + ShaderType.Float2 -> Float2(node) + ShaderType.Float3 -> Float3(node) + ShaderType.Float4 -> Float4(node) + ShaderType.Half1 -> Half1(node) + ShaderType.Half3 -> Half3(node) + ShaderType.Half4 -> Half4(node) + ShaderType.Bool -> error("a branch/When arm cannot produce a bool value") +} + +@ExperimentalMirage +public interface Float1 : ShaderValue { + override val e: Expression + + @ExperimentalMirage + public companion object { + internal operator fun invoke(e: Expression): Float1 = Float1Node(e) + } +} + +@ExperimentalMirage +public interface Float2 : ShaderValue { + override val e: Expression + + @ExperimentalMirage + public companion object { + internal operator fun invoke(e: Expression): Float2 = Float2Node(e) + } +} + +@ExperimentalMirage +public interface Float3 : ShaderValue { + override val e: Expression + + @ExperimentalMirage + public companion object { + internal operator fun invoke(e: Expression): Float3 = Float3Node(e) + } +} + +@ExperimentalMirage +public interface Float4 : ShaderValue { + override val e: Expression + + @ExperimentalMirage + public companion object { + internal operator fun invoke(e: Expression): Float4 = Float4Node(e) + } +} + +@ExperimentalMirage +public interface Half1 : ShaderValue { + override val e: Expression + + @ExperimentalMirage + public companion object { + internal operator fun invoke(e: Expression): Half1 = Half1Node(e) + } +} + +@ExperimentalMirage +public interface Half3 : ShaderValue { + override val e: Expression + + @ExperimentalMirage + public companion object { + internal operator fun invoke(e: Expression): Half3 = Half3Node(e) + } +} + +@ExperimentalMirage +public interface Half4 : ShaderValue { + override val e: Expression + + @ExperimentalMirage + public companion object { + internal operator fun invoke(e: Expression): Half4 = Half4Node(e) + } +} + +/** A boolean value — the result of a comparison, consumed only by [guard] / [If]. */ +@ExperimentalMirage +public interface UBool : ShaderValue { + override val e: Expression + + @ExperimentalMirage + public companion object { + internal operator fun invoke(e: Expression): UBool = UBoolNode(e) + } +} + +// Concrete leaves. `internal`, so a value interface can only ever be a handle or one of these — the +// companion `invoke`s above are the sole way to wrap a traced node. +internal class Float1Node(override val e: Expression) : Float1 +internal class Float2Node(override val e: Expression) : Float2 +internal class Float3Node(override val e: Expression) : Float3 +internal class Float4Node(override val e: Expression) : Float4 +internal class Half1Node(override val e: Expression) : Half1 +internal class Half3Node(override val e: Expression) : Half3 +internal class Half4Node(override val e: Expression) : Half4 +internal class UBoolNode(override val e: Expression) : UBool + +/** + * The comparisons Foil/Specular's guards need (`sdf > SMOOTH_EDGE_PX`, `pixel.a <= 0.0`). The names + * reuse GLSL's vector relational builtins (`greaterThan`/`lessThanEqual`/...), but note the semantics + * differ: our infix compares two **scalars** and yields a [UBool], where GLSL's builtins compare vector + * components element-wise and yield a `bvec` — same spelling, scalar meaning. + */ +@ExperimentalMirage +public infix fun Float1.greaterThan(o: Float1): UBool = UBool(Comparison(">", e, o.e)) + +@ExperimentalMirage +public infix fun Float1.greaterThan(o: Float): UBool = UBool(Comparison(">", e, Literal(o))) + +@ExperimentalMirage +public infix fun Half1.lessThanEqual(o: Float): UBool = UBool(Comparison("<=", e, Literal(o))) + +/** `&&` — Specular's highlight gate (`edge > 0.0 && specStrength > 0.0`). */ +@ExperimentalMirage +public infix fun UBool.and(o: UBool): UBool = UBool(Comparison("&&", e, o.e)) + +@ExperimentalMirage +public operator fun Float1.plus(o: Float1): Float1 = Float1(Binary("+", e, o.e, ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float1.minus(o: Float1): Float1 = Float1(Binary("-", e, o.e, ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float1.times(o: Float1): Float1 = Float1(Binary("*", e, o.e, ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float1.div(o: Float1): Float1 = Float1(Binary("/", e, o.e, ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float1.unaryMinus(): Float1 = Float1(Unary("-", e, ShaderType.Float1)) + +// Float literals directly on the left/right of a scalar op, so a body can write `0.5f * along` and +// `t * 0.25f` without a `float1(...)` wrapper. Distinct parameter types from the built-in +// `Float.times(Float)`, so there is no ambiguity. +@ExperimentalMirage +public operator fun Float1.plus(o: Float): Float1 = + Float1(Binary("+", e, Literal(o), ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float.plus(o: Float1): Float1 = + Float1(Binary("+", Literal(this), o.e, ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float1.minus(o: Float): Float1 = + Float1(Binary("-", e, Literal(o), ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float.minus(o: Float1): Float1 = + Float1(Binary("-", Literal(this), o.e, ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float1.times(o: Float): Float1 = + Float1(Binary("*", e, Literal(o), ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float.times(o: Float1): Float1 = + Float1(Binary("*", Literal(this), o.e, ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float1.div(o: Float): Float1 = + Float1(Binary("/", e, Literal(o), ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float.div(o: Float1): Float1 = + Float1(Binary("/", Literal(this), o.e, ShaderType.Float1)) + +@ExperimentalMirage +public operator fun Float2.plus(o: Float2): Float2 = Float2(Binary("+", e, o.e, ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float2.plus(o: Float1): Float2 = Float2(Binary("+", e, o.e, ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float2.plus(o: Float): Float2 = + Float2(Binary("+", e, Literal(o), ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float2.minus(o: Float2): Float2 = Float2(Binary("-", e, o.e, ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float2.times(o: Float1): Float2 = Float2(Binary("*", e, o.e, ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float2.times(o: Float): Float2 = + Float2(Binary("*", e, Literal(o), ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float2.times(o: Float2): Float2 = Float2(Binary("*", e, o.e, ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float2.div(o: Float1): Float2 = Float2(Binary("/", e, o.e, ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float2.div(o: Float2): Float2 = Float2(Binary("/", e, o.e, ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float2.unaryMinus(): Float2 = Float2(Unary("-", e, ShaderType.Float2)) + +@ExperimentalMirage +public operator fun Float3.plus(o: Float3): Float3 = Float3(Binary("+", e, o.e, ShaderType.Float3)) + +@ExperimentalMirage +public operator fun Float3.minus(o: Float1): Float3 = Float3(Binary("-", e, o.e, ShaderType.Float3)) + +@ExperimentalMirage +public operator fun Float3.minus(o: Float): Float3 = + Float3(Binary("-", e, Literal(o), ShaderType.Float3)) + +@ExperimentalMirage +public operator fun Float3.times(o: Float1): Float3 = Float3(Binary("*", e, o.e, ShaderType.Float3)) + +@ExperimentalMirage +public operator fun Float3.times(o: Float): Float3 = + Float3(Binary("*", e, Literal(o), ShaderType.Float3)) + +@ExperimentalMirage +public operator fun Float1.times(o: Float3): Float3 = Float3(Binary("*", e, o.e, ShaderType.Float3)) + +@ExperimentalMirage +public operator fun Float3.times(o: Float3): Float3 = Float3(Binary("*", e, o.e, ShaderType.Float3)) + +@ExperimentalMirage +public operator fun Float3.div(o: Float1): Float3 = Float3(Binary("/", e, o.e, ShaderType.Float3)) + +@ExperimentalMirage +public operator fun Half3.plus(o: Half3): Half3 = Half3(Binary("+", e, o.e, ShaderType.Half3)) + +@ExperimentalMirage +public operator fun Half3.minus(o: Half3): Half3 = Half3(Binary("-", e, o.e, ShaderType.Half3)) + +@ExperimentalMirage +public operator fun Half3.times(o: Half3): Half3 = Half3(Binary("*", e, o.e, ShaderType.Half3)) + +@ExperimentalMirage +public operator fun Half3.times(o: Half1): Half3 = Half3(Binary("*", e, o.e, ShaderType.Half3)) + +@ExperimentalMirage +public operator fun Half1.times(o: Half3): Half3 = Half3(Binary("*", e, o.e, ShaderType.Half3)) + +/** + * `half3 * float` — GLSL's implicit scalar-widening lets a `float` multiply a `half3` directly (as + * `SPECULAR_KERNEL_AGSL`'s `(1.0 - pixel.rgb) * clamp(highlight, 0.0, 1.0)` does); the eDSL keeps the + * operand a plain [Float1] here rather than forcing an explicit `half()` cast the source doesn't have. + */ +@ExperimentalMirage +public operator fun Half3.times(o: Float1): Half3 = Half3(Binary("*", e, o.e, ShaderType.Half3)) + +/** `half3(1.0)` — a broadcast constant, e.g. the `(1.0 - pixel.rgb)` screen-blend complement. */ +@ExperimentalMirage +public fun half3(scalar: Float): Half3 = + Half3(Call("half3", listOf(Literal(scalar)), ShaderType.Half3)) + +/** `.x` / `.y` on a `float2`. */ +@ExperimentalMirage +public val Float2.x: Float1 get() = Float1(Swizzle(e, "x", ShaderType.Float1)) + +@ExperimentalMirage +public val Float2.y: Float1 get() = Float1(Swizzle(e, "y", ShaderType.Float1)) + +/** `.x` on a `float3` (the `p.x >= 0.0` sign-select pattern). */ +@ExperimentalMirage +public val Float3.x: Float1 get() = Float1(Swizzle(e, "x", ShaderType.Float1)) + +/** `.xyz` on a `float4` (Chromatic's `chromaticKRGB.xyz` — the uniform's `.w` is declared but unused). */ +@ExperimentalMirage +public val Float4.xyz: Float3 get() = Float3(Swizzle(e, "xyz", ShaderType.Float3)) + +/** + * `.rgb` on a `half4`: read narrows to `half3`, and — on a `var pixel by local(...)` — *write* lowers to + * `pixel.rgb = ...;` in place, exactly as the GLSL original writes a channel (instead of rebuilding the + * whole `half4(processColor(pixel.rgb, ...), pixel.a)`). + * + * Only `plus` is defined on [Half3], never `plusAssign`, so `pixel.rgb += x` lowers as get→plus→set — one + * `.rgb =` write — automatically; defining `plusAssign` alongside `plus` would be a compile error (Kotlin + * rejects the assign-operator ambiguity), so it must never be added. + * + * The write only makes sense on a mutable local (a value whose node is a [VarRef]); writing a channel of a + * computed `half4` has no target, so [localName] fails loudly there. The target `"$name.rgb"` prints + * verbatim; the hoist leaves it alone (a [VarRef] is un-liftable) and only lifts the assigned value's CSEs. + */ +@ExperimentalMirage +public var Half4.rgb: Half3 + get() = Half3(Swizzle(e, "rgb", ShaderType.Half3)) + set(value) { + activeTrace().statements += Reassign("${localName(e, "rgb")}.rgb", value.e) + } + +@ExperimentalMirage +public var Half4.a: Half1 + get() = Half1(Swizzle(e, "a", ShaderType.Half1)) + set(value) { + activeTrace().statements += Reassign("${localName(e, "a")}.a", value.e) + } + +/** The backing local name a write-swizzle targets, or a loud failure if the value is not a mutable local. */ +private fun localName(node: Expression, channel: String): String = (node as? VarRef)?.name + ?: error( + "cannot write .$channel of a computed half4 — write-swizzle needs a `var x by local(...)`", + ) + +@ExperimentalMirage +public fun float1(v: Float): Float1 = Float1(Literal(v, ShaderType.Float1)) + +@ExperimentalMirage +public fun float2(x: Float1, y: Float1): Float2 = + Float2(Call("float2", listOf(x.e, y.e), ShaderType.Float2)) + +@ExperimentalMirage +public fun float2(x: Float, y: Float): Float2 = + Float2(Call("float2", listOf(Literal(x), Literal(y)), ShaderType.Float2)) + +@ExperimentalMirage +public fun float3(x: Float, y: Float, z: Float): Float3 = Float3( + Call("float3", listOf(Literal(x), Literal(y), Literal(z)), ShaderType.Float3), +) + +@ExperimentalMirage +public fun float3(v: Float1): Float3 = Float3(Call("float3", listOf(v.e), ShaderType.Float3)) + +@ExperimentalMirage +public fun float3(xy: Float2, z: Float1): Float3 = + Float3(Call("float3", listOf(xy.e, z.e), ShaderType.Float3)) + +@ExperimentalMirage +public fun float3(xy: Float2, z: Float): Float3 = + Float3(Call("float3", listOf(xy.e, Literal(z)), ShaderType.Float3)) + +/** + * `float4(0.0, 0.0, 0.0, 0.0)` — Specular's transparent-tint argument to `processColor`. A [Float4], + * like every other value type, so a body can pass it around; the emitter prints the `float4(...)` + * constructor verbatim. + */ +@ExperimentalMirage +public fun float4(x: Float, y: Float, z: Float, w: Float): Float4 = + Float4(Call("float4", listOf(Literal(x), Literal(y), Literal(z), Literal(w)), ShaderType.Float4)) + +@ExperimentalMirage +public fun half3(v: Float3): Half3 = Half3(Call("half3", listOf(v.e), ShaderType.Half3)) + +@ExperimentalMirage +public fun half(v: Float1): Half1 = Half1(Call("half", listOf(v.e), ShaderType.Half1)) + +@ExperimentalMirage +public fun half4(rgb: Half3, a: Half1): Half4 = + Half4(Call("half4", listOf(rgb.e, a.e), ShaderType.Half4)) + +@ExperimentalMirage +public fun mix(a: Half4, b: Half4, t: Float1): Half4 = + Half4(Call("mix", listOf(a.e, b.e, t.e), ShaderType.Half4)) + +/** `half4(0.0)` — the all-channels-zero constructor Foil's lens-bounds early-out returns. */ +@ExperimentalMirage +public fun half4(scalar: Float): Half4 = + Half4(Call("half4", listOf(Literal(scalar)), ShaderType.Half4)) + +/** `cond ? ifTrue : ifFalse` — Kotlin has no ternary to overload, so this is the DSL spelling of one. */ +@ExperimentalMirage +public fun select(condition: UBool, ifTrue: Float1, ifFalse: Float1): Float1 = + Float1(Select(condition.e, ifTrue.e, ifFalse.e, ShaderType.Float1)) + +@ExperimentalMirage +public infix fun Float1.greaterThanEqual(o: Float): UBool = UBool(Comparison(">=", e, Literal(o))) + +/** + * `p.x >= 0.0 ? 1.0 : -1.0` — the sign-select the superellipse bevel direction needs on each axis. + * Shared by the Specular and Chromatic bodies (both build the same bevel field). + */ +@ExperimentalMirage +public fun signSelect(v: Float1): Float1 = select(v greaterThanEqual 0f, float1(1f), float1(-1f)) + +@ExperimentalMirage +public fun min(a: Float1, b: Float1): Float1 = + Float1(Call("min", listOf(a.e, b.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun max(a: Float1, b: Float1): Float1 = + Float1(Call("max", listOf(a.e, b.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun max(a: Float1, b: Float): Float1 = + Float1(Call("max", listOf(a.e, Literal(b)), ShaderType.Float1)) + +@ExperimentalMirage +public fun max(a: Half1, b: Half1): Half1 = Half1(Call("max", listOf(a.e, b.e), ShaderType.Half1)) + +@ExperimentalMirage +public fun exp(v: Float1): Float1 = Float1(Call("exp", listOf(v.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun abs(a: Float1): Float1 = Float1(Call("abs", listOf(a.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun abs(a: Float2): Float2 = Float2(Call("abs", listOf(a.e), ShaderType.Float2)) + +@ExperimentalMirage +public fun abs(a: Float3): Float3 = Float3(Call("abs", listOf(a.e), ShaderType.Float3)) + +@ExperimentalMirage +public fun clamp(a: Float1, lo: Float1, hi: Float1): Float1 = + Float1(Call("clamp", listOf(a.e, lo.e, hi.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun clamp(a: Float1, lo: Float, hi: Float): Float1 = + Float1(Call("clamp", listOf(a.e, Literal(lo), Literal(hi)), ShaderType.Float1)) + +@ExperimentalMirage +public fun clamp(a: Float3, lo: Float1, hi: Float1): Float3 = + Float3(Call("clamp", listOf(a.e, lo.e, hi.e), ShaderType.Float3)) + +@ExperimentalMirage +public fun clamp(a: Float3, lo: Float, hi: Float): Float3 = + Float3(Call("clamp", listOf(a.e, Literal(lo), Literal(hi)), ShaderType.Float3)) + +@ExperimentalMirage +public fun smoothstep(lo: Float1, hi: Float1, x: Float1): Float1 = + Float1(Call("smoothstep", listOf(lo.e, hi.e, x.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun smoothstep(lo: Float, hi: Float, x: Float1): Float1 = + Float1(Call("smoothstep", listOf(Literal(lo), Literal(hi), x.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun smoothstep(lo: Float, hi: Float1, x: Float1): Float1 = + Float1(Call("smoothstep", listOf(Literal(lo), hi.e, x.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun smoothstep(lo: Float1, hi: Float, x: Float1): Float1 = + Float1(Call("smoothstep", listOf(lo.e, Literal(hi), x.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun step(edge: Float1, x: Float1): Float1 = + Float1(Call("step", listOf(edge.e, x.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun step(edge: Float, x: Float1): Float1 = + Float1(Call("step", listOf(Literal(edge), x.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun length(v: Float2): Float1 = Float1(Call("length", listOf(v.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun normalize(v: Float2): Float2 = Float2(Call("normalize", listOf(v.e), ShaderType.Float2)) + +@ExperimentalMirage +public fun normalize(v: Float3): Float3 = Float3(Call("normalize", listOf(v.e), ShaderType.Float3)) + +@ExperimentalMirage +public fun dot(a: Float2, b: Float2): Float1 = + Float1(Call("dot", listOf(a.e, b.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun dot(a: Float3, b: Float3): Float1 = + Float1(Call("dot", listOf(a.e, b.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun pow(base: Float1, exponent: Float1): Float1 = + Float1(Call("pow", listOf(base.e, exponent.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun pow(base: Float1, exponent: Float): Float1 = + Float1(Call("pow", listOf(base.e, Literal(exponent)), ShaderType.Float1)) + +@ExperimentalMirage +public fun sqrt(v: Float1): Float1 = Float1(Call("sqrt", listOf(v.e), ShaderType.Float1)) + +/** + * `processColor(src, vibrancy, intensity, overlay)` — the preamble helper every Composite/Generate + * kernel already has in scope (MiragePreamble.kt); Specular calls it at saturation=1/contrast=1/ + * transparent-overlay (identity, kept only for bit-parity with the hand-written kernel). + */ +@ExperimentalMirage +public fun processColor(src: Half3, vibrancy: Float1, intensity: Float1, overlay: Float4): Half3 = + Half3(Call("processColor", listOf(src.e, vibrancy.e, intensity.e, overlay.e), ShaderType.Half3)) + +@ExperimentalMirage +public fun processColor(src: Half3, vibrancy: Float, intensity: Float, overlay: Float4): Half3 = + Half3( + Call( + "processColor", + listOf(src.e, Literal(vibrancy), Literal(intensity), overlay.e), + ShaderType.Half3, + ), + ) + +@ExperimentalMirage +public fun fract(v: Float1): Float1 = Float1(Call("fract", listOf(v.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun fract(v: Float2): Float2 = Float2(Call("fract", listOf(v.e), ShaderType.Float2)) + +@ExperimentalMirage +public fun fract(v: Float3): Float3 = Float3(Call("fract", listOf(v.e), ShaderType.Float3)) + +@ExperimentalMirage +public fun floor(v: Float2): Float2 = Float2(Call("floor", listOf(v.e), ShaderType.Float2)) + +@ExperimentalMirage +public fun sin(v: Float1): Float1 = Float1(Call("sin", listOf(v.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun cos(v: Float3): Float3 = Float3(Call("cos", listOf(v.e), ShaderType.Float3)) + +@ExperimentalMirage +public fun mix(a: Float1, b: Float1, t: Float1): Float1 = + Float1(Call("mix", listOf(a.e, b.e, t.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun mix(a: Float1, b: Float1, t: Float): Float1 = + Float1(Call("mix", listOf(a.e, b.e, Literal(t)), ShaderType.Float1)) + +@ExperimentalMirage +public fun mix(a: Float, b: Float1, t: Float1): Float1 = + Float1(Call("mix", listOf(Literal(a), b.e, t.e), ShaderType.Float1)) + +@ExperimentalMirage +public fun mix(a: Float3, b: Float3, t: Float1): Float3 = + Float3(Call("mix", listOf(a.e, b.e, t.e), ShaderType.Float3)) + +@ExperimentalMirage +public fun mix(a: Float3, b: Float3, t: Float): Float3 = + Float3(Call("mix", listOf(a.e, b.e, Literal(t)), ShaderType.Float3)) + +/** + * `boxRoundedSDF(p, halfDim, r)` — signed distance to the rounded lens box (negative inside), a + * preamble helper (MiragePreamble.kt) every Composite/Generate lens kernel already has in scope. + */ +@ExperimentalMirage +public fun boxRoundedSDF(p: Float2, halfDim: Float2, r: Float1): Float1 = + Float1(Call("boxRoundedSDF", listOf(p.e, halfDim.e, r.e), ShaderType.Float1)) + +/** `lensNormalDirection(p, halfDim, r)` — outward lens-surface direction; a preamble helper. */ +@ExperimentalMirage +public fun lensNormalDirection(p: Float2, halfDim: Float2, r: Float1): Float2 = + Float2(Call("lensNormalDirection", listOf(p.e, halfDim.e, r.e), ShaderType.Float2)) + +/** Rec. 709 luma weights — the same constant the hand-written Duotone kernel uses. */ +@ExperimentalMirage +public fun luma(c: Half3): Half1 = Half1(Call("mirage_luma", listOf(c.e), ShaderType.Half1)) + +@ExperimentalMirage +public fun mix(a: Half3, b: Half3, t: Half1): Half3 = + Half3(Call("mix", listOf(a.e, b.e, t.e), ShaderType.Half3)) + +/** `mix(half3, half3, float)` — GLSL widens the `float` t, so Duotone blends by a bare `float` amount. */ +@ExperimentalMirage +public fun mix(a: Half3, b: Half3, t: Float1): Half3 = + Half3(Call("mix", listOf(a.e, b.e, t.e), ShaderType.Half3)) + +@ExperimentalMirage +public fun mix(a: Half1, b: Half1, t: Half1): Half1 = + Half1(Call("mix", listOf(a.e, b.e, t.e), ShaderType.Half1)) + +/** + * `foilHash(c)` — a bounded-input hash so `sin()` never blows up at scale, declared as a top-level + * helper function ahead of `main` (the same shape [com.skydoves.cloudy.internal.MirageKernels]' + * hand-written Foil kernel uses). Registers the function on the active trace the first time it is + * called and returns the call node, so the body just writes `foilHash(cell)`. + */ +@ExperimentalMirage +public fun foilHash(c: Float2): Float1 { + activeTrace().addHelper( + HelperFunction( + name = "foilHash", + params = listOf("c" to ShaderType.Float2), + returnType = ShaderType.Float1, + body = emptyList(), + returnExpr = fract( + sin(dot(Float2(Argument("c", ShaderType.Float2)), float2(127.1f, 311.7f))) * 43758.5453f, + ).e, + ), + ) + return Float1(Call("foilHash", listOf(c.e), ShaderType.Float1)) +} + +// --- Host-type literals: bake a Compose Color/Offset/Size into the source as a constructor call, so a +// body can write a design-token color or a geometry constant without hand-expanding its channels. --- + +/** + * A `half4` color literal from packed `0xAARRGGBB` bits, e.g. `color(0xFF1B1B3A)`. + * + * Unlike a `layout(color)` uniform, a literal is baked into the source and receives **no** color-space + * conversion — its channels are emitted as-is (sRGB). For a color that must match a `layout(color)` + * uniform's working-space value, pass it as a uniform, not a literal. + */ +@ExperimentalMirage +public fun color(argb: Long): Half4 { + val a = ((argb shr 24) and 0xFF) / 255f + val r = ((argb shr 16) and 0xFF) / 255f + val g = ((argb shr 8) and 0xFF) / 255f + val b = (argb and 0xFF) / 255f + return Half4( + Call("half4", listOf(Literal(r), Literal(g), Literal(b), Literal(a)), ShaderType.Half4), + ) +} + +/** + * A `half4` color literal from a Compose [Color] (e.g. an app design token). Its raw sRGB channels are + * emitted verbatim — see [color] for the color-space note (no `layout(color)` conversion is applied). + */ +@ExperimentalMirage +public fun color(c: Color): Half4 = Half4( + Call( + "half4", + listOf(Literal(c.red), Literal(c.green), Literal(c.blue), Literal(c.alpha)), + ShaderType.Half4, + ), +) + +/** A `float2` literal from a Compose [Offset] (`float2(o.x, o.y)`). */ +@ExperimentalMirage +public fun offset(o: Offset): Float2 = + Float2(Call("float2", listOf(Literal(o.x), Literal(o.y)), ShaderType.Float2)) + +/** A `float2` literal from a Compose [Size] (`float2(s.width, s.height)`). */ +@ExperimentalMirage +public fun size(s: Size): Float2 = + Float2(Call("float2", listOf(Literal(s.width), Literal(s.height)), ShaderType.Float2)) + +/** + * `mix(half4, color, t)` — convenience so a body can blend toward a literal [Color] without wrapping it + * in [color] first. The literal's raw sRGB channels are baked in ([color]'s color-space note applies). + */ +@ExperimentalMirage +public fun mix(a: Half4, b: Color, t: Float1): Half4 = mix(a, color(b), t) + +/** + * `.eval(coord)` — samples an arbitrary `uniform shader` texture child (RainyWindow's + * `wipeMask` finger-wipe mask), the texture counterpart of [sampleContent]. Position-dependent: each + * tap stays where written (the four bilinear taps of a mask read are never CSE-merged) — see + * [SampleTexture]. + */ +@ExperimentalMirage +public fun UTexture.eval(coord: Float2): Half4 = + Half4(SampleTexture(UniformRef(slot, ShaderType.Half4), coord.e)) + +// --- Builtins / operators / swizzles the RainyWindow ("Heartfelt") port needs, added one-per-line in +// the same factory style as the block above. Each mirrors an AGSL/SkSL builtin or GLSL operator the +// hand-written kernel spells out. --- + +/** `mod(x, y)` — GLSL floating modulo (`x - y * floor(x/y)`), a real AGSL/SkSL builtin. */ +@ExperimentalMirage +public fun mod(a: Float1, b: Float): Float1 = + Float1(Call("mod", listOf(a.e, Literal(b)), ShaderType.Float1)) + +/** `float * float2` — GLSL scalar-broadcast on the left (`0.5 * res`). */ +@ExperimentalMirage +public operator fun Float.times(o: Float2): Float2 = + Float2(Binary("*", Literal(this), o.e, ShaderType.Float2)) + +/** `float2 - float` — GLSL scalar-broadcast subtract (`res - 0.5`). */ +@ExperimentalMirage +public operator fun Float2.minus(o: Float): Float2 = + Float2(Binary("-", e, Literal(o), ShaderType.Float2)) + +/** `max(float2, float2)` — component-wise max (`max(res, float2(1.0))`, the resolution floor). */ +@ExperimentalMirage +public fun max(a: Float2, b: Float2): Float2 = + Float2(Call("max", listOf(a.e, b.e), ShaderType.Float2)) + +/** `min(float1, float)` — scalar min (`min(nLen, 24.0)`, the refraction-offset length clamp). */ +@ExperimentalMirage +public fun min(a: Float1, b: Float): Float1 = + Float1(Call("min", listOf(a.e, Literal(b)), ShaderType.Float1)) + +/** `max(half1, float)` — the droplet-brightness floor (`max(0.0, sin(...) - st.y)` in `DropLayer2`). */ +@ExperimentalMirage +public fun max(a: Half1, b: Float): Half1 = + Half1(Call("max", listOf(a.e, Literal(b)), ShaderType.Half1)) + +/** `clamp` over `float2` with `float2` bounds (`clamp(tap, float2(0.5), res - 0.5)`). */ +@ExperimentalMirage +public fun clamp(a: Float2, lo: Float2, hi: Float2): Float2 = + Float2(Call("clamp", listOf(a.e, lo.e, hi.e), ShaderType.Float2)) + +/** `clamp` over `float2` with scalar bounds (`clamp(xy / res, 0.0, 1.0)`). */ +@ExperimentalMirage +public fun clamp(a: Float2, lo: Float, hi: Float): Float2 = + Float2(Call("clamp", listOf(a.e, Literal(lo), Literal(hi)), ShaderType.Float2)) + +/** + * `clamp(float(half1), float, float)` — the mask-read channel clamp + * (`clamp(float(wipeMask.eval(...).r), 0, 1)`). The `half` read is cast to `float` so the call + * resolves against SkSL/AGSL's `clamp($genType, float, float)` overload instead of relying on + * implicit `half`→`float` promotion; this mirrors the original hand-written kernel's explicit cast. + */ +@ExperimentalMirage +public fun clamp(a: Half1, lo: Float, hi: Float): Float1 = Float1( + Call( + "clamp", + listOf(Call("float", listOf(a.e), ShaderType.Float1), Literal(lo), Literal(hi)), + ShaderType.Float1, + ), +) + +/** `mix(float, float, float1)` — bare-float endpoints with a value blend factor (`mix(3.0, 6.0, amount)`). */ +@ExperimentalMirage +public fun mix(a: Float, b: Float, t: Float1): Float1 = + Float1(Call("mix", listOf(Literal(a), Literal(b), t.e), ShaderType.Float1)) + +/** `half4 + half4` — the 5-tap box-blur accumulation (`sharpBg + content.eval(...) + ...`). */ +@ExperimentalMirage +public operator fun Half4.plus(o: Half4): Half4 = Half4(Binary("+", e, o.e, ShaderType.Half4)) + +/** `mix(half4, half4, half1)` — blend by a `half` factor (`mix(sharpBg, blur, half(foggy))`). */ +@ExperimentalMirage +public fun mix(a: Half4, b: Half4, t: Half1): Half4 = + Half4(Call("mix", listOf(a.e, b.e, t.e), ShaderType.Half4)) + +/** `half4 * half1` — normalizing the blur sum (`blur *= half(1.0 / 5.0)`). */ +@ExperimentalMirage +public operator fun Half4.times(o: Half1): Half4 = Half4(Binary("*", e, o.e, ShaderType.Half4)) + +/** `half1 * half1` — `half(foggy) * half(hazeStrength)`. */ +@ExperimentalMirage +public operator fun Half1.times(o: Half1): Half1 = Half1(Binary("*", e, o.e, ShaderType.Half1)) + +/** `half3(r, g, b)` from bare floats — the haze constant `half3(0.86, 0.90, 0.94)`. */ +@ExperimentalMirage +public fun half3(r: Float, g: Float, b: Float): Half3 = + Half3(Call("half3", listOf(Literal(r), Literal(g), Literal(b)), ShaderType.Half3)) + +/** `float3(x, y, z)` from three scalar expressions (`float3(p, p, p)`, the hash lane spread). */ +@ExperimentalMirage +public fun float3(x: Float1, y: Float1, z: Float1): Float3 = + Float3(Call("float3", listOf(x.e, y.e, z.e), ShaderType.Float3)) + +/** `half(v)` from a bare Kotlin float (`half(1.0 / 5.0)`, the box-blur normalizer). */ +@ExperimentalMirage +public fun half(v: Float): Half1 = Half1(Call("half", listOf(Literal(v)), ShaderType.Half1)) + +// Swizzles the drop-field math reads. + +/** `.yx` on a `float2` — component swap (`(st - p) * a.yx`, the anisotropic drop-distance stretch). */ +@ExperimentalMirage +public val Float2.yx: Float2 get() = Float2(Swizzle(e, "yx", ShaderType.Float2)) + +/** `.y` on a `float3` — the green lane of a hash triple (`p3.y` in `N13`). */ +@ExperimentalMirage +public val Float3.y: Float1 get() = Float1(Swizzle(e, "y", ShaderType.Float1)) + +/** `.z` on a `float3` — the blue lane of a hash triple (`p3.z` / `n.z` in `N13`/`DropLayer2`). */ +@ExperimentalMirage +public val Float3.z: Float1 get() = Float1(Swizzle(e, "z", ShaderType.Float1)) + +/** `.xy` on a `float3` — the first two hash lanes (`n.xy` seeding the static-drop offset). */ +@ExperimentalMirage +public val Float3.xy: Float2 get() = Float2(Swizzle(e, "xy", ShaderType.Float2)) + +/** `.yzx` on a `float3` — the rotated-lane self-dot term (`p3.yzx + 19.19` in `N13`). */ +@ExperimentalMirage +public val Float3.yzx: Float3 get() = Float3(Swizzle(e, "yzx", ShaderType.Float3)) + +/** `.r` on a `half4` — the wipe-mask alpha/red channel read. */ +@ExperimentalMirage +public val Half4.r: Half1 get() = Half1(Swizzle(e, "r", ShaderType.Half1)) + +/** `float3 + float` scalar-broadcast (`p3.yzx + 19.19`). */ +@ExperimentalMirage +public operator fun Float3.plus(o: Float): Float3 = + Float3(Binary("+", e, Literal(o), ShaderType.Float3)) + +/** `float3 + float1` scalar-broadcast (`p3 += dot(...)`, adding a scalar to every lane). */ +@ExperimentalMirage +public operator fun Float3.plus(o: Float1): Float3 = Float3(Binary("+", e, o.e, ShaderType.Float3)) diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/TraceContext.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/TraceContext.kt new file mode 100644 index 00000000..e27bb6e1 --- /dev/null +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/TraceContext.kt @@ -0,0 +1,295 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class, ExperimentalAtomicApi::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.reflect.KProperty + +/** + * Accumulates the statements a kernel body records as it runs — early-return [guard]s, [If] blocks, + * and mutable-[local] reassignments — up to the final returned value. One flat [statements] list backs + * the whole trace, including nested [If] bodies: [ifBlock] remembers the list length before running the + * block and re-wraps whatever the block appended (in place, as one [IfBlock]) rather than routing to a + * per-scope list. That splice is what lets a [local]'s reassignment (which always targets *this* list) + * land correctly inside a nested `If` — a per-block child list would make the reassignment blind to + * which block is currently open. + */ +internal class TraceContext { + val statements: MutableList = mutableListOf() + + /** + * User-defined helper functions the body called (Foil's `foilHash`), de-duplicated by name so a + * helper referenced more than once is still declared once. Spliced ahead of the kernel by the emitter. + */ + val helpers: MutableList = mutableListOf() + private var nextVarId = 0 + + fun freshName(): String = "_v${nextVarId++}" + + fun addHelper(helper: HelperFunction) { + if (helpers.none { it.name == helper.name }) helpers += helper + } + + fun hasHelper(name: String): Boolean = helpers.any { it.name == name } + + /** + * Traces a multi-statement helper body once, returning the statements it recorded and its returned + * expression. Runs [block] against *this* trace (so intrinsics inside the helper — including calls to + * *other* helpers — find the open trace and register on it), then splices out the statements [block] + * appended so they land in the helper's own [HelperFunction.body], not the main kernel body. Mirrors + * [ifBlock]'s splice, but yields the captured statements to the caller instead of re-wrapping them. + * + * A helper calling another helper registers that callee on this trace *before* the caller finishes + * tracing (its intrinsic runs mid-[block]), so the shared [helpers] list ends up dependency-first — + * every helper is appended after its callees, which is exactly the GLSL "declared before use" order. + */ + fun traceHelper(block: () -> R): Pair, R> { + val start = statements.size + val result = block() + val body = statements.subList(start, statements.size).toList() + statements.subList(start, statements.size).clear() + return body to result + } + + /** + * Runs [block], then folds every statement it appended into one [IfBlock] guarded by [condition] — + * see the class KDoc for why this in-place splice, not a child scope, is what keeps a nested + * reassignment landing inside the emitted `if`. + */ + fun ifBlock(condition: Expression, block: () -> Unit) { + statements += IfBlock(condition, splice(block)) + } + + /** + * Records a bounded [LoopStatement]: runs [block] **once** at trace time (the surface [loop] passes it + * a `float(_i)` index expression, not a live counter — the body is emitted, not unrolled) and folds the + * statements it appended into the loop body, mirroring [ifBlock]'s in-place splice. + */ + fun loop(count: Expression, maxIterations: Int, indexName: String, block: () -> Unit) { + statements += LoopStatement(count, maxIterations, indexName, splice(block)) + } + + /** Runs [block], then removes and returns the statements it appended (the [ifBlock]/[loop] splice). */ + private fun splice(block: () -> Unit): List { + val start = statements.size + block() + val body = statements.subList(start, statements.size).toList() + statements.subList(start, statements.size).clear() + return body + } +} + +/** + * The one live trace, or `null` outside a shader body. A single global slot, not a parameter threaded + * through every intrinsic, so a plain `val g = luma(src.rgb)` reads as ordinary Kotlin — the intrinsics + * find the trace here. A trace runs exactly once per shader construction (then the source is cached + * forever), so contention is a non-goal; the CAS only turns an accidental nested/concurrent trace into + * a loud failure instead of silent statement interleaving. Mirrors MirageProgramCache.kt's stdlib + * [AtomicReference] use (KMP `commonMain` has no `ThreadLocal`). + */ +private val currentTrace = AtomicReference(null) + +/** + * Installs a fresh [TraceContext], runs [body] with [params] as receiver so the body reads its uniform + * handles bare, and returns the body's result paired with the recorded trace. The [check] fails loudly + * if a trace is already open (a nested or concurrent shader construction), which would otherwise + * interleave two bodies' statements into one list. + */ +internal fun trace(params: P, body: P.() -> R): Pair { + val ctx = TraceContext() + if (!currentTrace.compareAndSet(null, ctx)) { + throw MirageDiagnosticException( + MirageDiagnosticCode.NESTED_TRACE, + "a mirage shader body started while another was still tracing", + "build one shader at a time; do not construct a MirageShader from inside another's body lambda", + ) + } + try { + return params.body() to ctx + } finally { + currentTrace.store(null) + } +} + +/** The open trace, or a loud failure when an intrinsic that records a statement runs outside a body. */ +internal fun activeTrace(): TraceContext = currentTrace.load() ?: throw MirageDiagnosticException( + MirageDiagnosticCode.INTRINSIC_OUTSIDE_BODY, + "a mirage intrinsic was used outside a shader body", + "call it only inside a colorize/composite/generate body lambda", +) + +/** + * `mirageTime` — the standard, name-gated animation clock (MirageCompiler.kt's STD_TIME). A top-level + * value, not a trace member: it only mints a [StandardUniform] node (referencing it is what makes the + * compiler emit the declaration), so it needs no open trace. + */ +@ExperimentalMirage +public val mirageTime: Float1 get() = Float1(StandardUniform("mirageTime", ShaderType.Float1)) + +/** + * `mirageResolution` — the standard, name-gated render resolution (MirageCompiler.kt's STD_RESOLUTION), + * a `float2` of the target size in px. Like [mirageTime]: a top-level value that only mints a + * [StandardUniform] node, so referencing it is what makes the compiler emit the `uniform float2` + * declaration; needs no open trace. + */ +@ExperimentalMirage +public val mirageResolution: Float2 get() = + Float2(StandardUniform("mirageResolution", ShaderType.Float2)) + +/** `if () return ;` — Foil's lens-bounds early-out is this shape exactly. */ +@ExperimentalMirage +public fun guard(condition: UBool, value: () -> Half4) { + activeTrace().statements += EarlyReturn(condition.e, value().e) +} + +/** `if () { }` — Specular's highlight block; see [TraceContext.ifBlock]. */ +@ExperimentalMirage +public fun If(condition: UBool, block: () -> Unit) { + activeTrace().ifBlock(condition.e, block) +} + +/** + * `var pixel by local(initial)` — a mutable local the body reassigns with `pixel = ...` (Specular's + * `pixel`, updated from a `content.eval` fallback and again inside the highlight `If`). The delegate's + * getter always reads the same source variable — a set never renames it — so a read before a later set + * and one after both refer to the same emitted local, exactly like a GLSL `pixel.rgb = ...` in place. + */ +@ExperimentalMirage +public fun local(initial: Half4): LocalHalf4 { + val name = activeTrace().freshName() + activeTrace().statements += Assign(name, initial.e) + return LocalHalf4(name) +} + +/** + * The delegate backing `var x by local(...)`; see [local]. The getter returns a plain [Half4] (so a + * whole-value reassignment `pixel = half4(...)` stays valid — the property type is [Half4], not a + * subtype); *channel* writes (`pixel.rgb = ...`) go through the [rgb]/[a] write-swizzle setters on + * [Half4], which resolve the backing var name from the value's own `VarRef` node. + */ +@ExperimentalMirage +public class LocalHalf4 internal constructor(private val name: String) { + public operator fun getValue(thisRef: Any?, property: KProperty<*>): Half4 = + Half4(VarRef(name, ShaderType.Half4)) + + public operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Half4) { + activeTrace().statements += Reassign(name, value.e) + } +} + +/** + * `var uv by local(initial)` for a `float2` — the mutable-local form the RainyWindow helpers need for + * their sequential in-place updates (`uv.y += t`, `x *= 0.7`, ... rewritten as full reassignment). Same + * shape as [local] over [Half4]; the getter always reads the one source variable, a set never renames. + */ +@ExperimentalMirage +public fun local(initial: Float2): LocalFloat2 { + val name = activeTrace().freshName() + activeTrace().statements += Assign(name, initial.e) + return LocalFloat2(name) +} + +/** The delegate backing `var v by local(float2)`; see [local]. */ +@ExperimentalMirage +public class LocalFloat2 internal constructor(private val name: String) { + public operator fun getValue(thisRef: Any?, property: KProperty<*>): Float2 = + Float2(VarRef(name, ShaderType.Float2)) + + public operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Float2) { + activeTrace().statements += Reassign(name, value.e) + } +} + +/** `var x by local(initial)` for a `float` scalar — see [local] over [Float2]. */ +@ExperimentalMirage +public fun local(initial: Float1): LocalFloat1 { + val name = activeTrace().freshName() + activeTrace().statements += Assign(name, initial.e) + return LocalFloat1(name) +} + +/** The delegate backing `var x by local(float1)`; see [local]. */ +@ExperimentalMirage +public class LocalFloat1 internal constructor(private val name: String) { + public operator fun getValue(thisRef: Any?, property: KProperty<*>): Float1 = + Float1(VarRef(name, ShaderType.Float1)) + + public operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Float1) { + activeTrace().statements += Reassign(name, value.e) + } +} + +/** + * Registers an N-param, statement-body helper function once (deduped by [name]) and returns the + * [Expression] a call to it produces. The library-facing counterpart to [foilHash]'s single-expression + * registration: [traceBody] runs against fresh [Argument] handles the caller builds for [params], + * declaring mutable [local]s and calling other helpers as needed, and returns the helper's final + * expression; everything it records under [TraceContext.traceHelper] becomes the helper's own body. + * + * The caller passes the [args] the call site supplies and the [returnType]; a `internal fun N13(...)` + * wrapper in the kernel file wraps the returned node in its value type. + */ +@ExperimentalMirage +internal fun defineHelper( + name: String, + params: List>, + returnType: ShaderType, + args: List, + traceBody: () -> Expression, +): Expression { + val trace = activeTrace() + if (!trace.hasHelper(name)) { + val (statements, returnExpr) = trace.traceHelper(traceBody) + trace.addHelper(HelperFunction(name, params, returnType, statements, returnExpr)) + } + return Call(name, args, returnType) +} + +/** `sampleContent(coord)` — the `content.eval(coord)` sample point, Composite-only. */ +@ExperimentalMirage +public fun sampleContent(coord: Float2): Half4 = Half4(SampleContent(coord.e)) + +/** + * Static loop unroll: runs [body] [times] times *at trace time* with a Kotlin `Int` index, so each pass + * records another copy of the body's statements — there is no GLSL `for`, the loop is fully unrolled into + * inline statements. The index is a compile-time constant, so `tapOffsets[i]` and `xy + float2(i, 0)` + * work as ordinary Kotlin. IR and emitter are untouched: this only replays the body. + */ +@ExperimentalMirage +public fun unroll(times: Int, body: (Int) -> Unit) { + activeTrace() // fail loudly outside a body, before the (possibly empty) loop runs nothing + for (i in 0 until times) body(i) +} + +/** + * ES2-safe dynamic-bound loop: emits a real GLSL `for` whose header bound is the Kotlin `Int` + * [maxIterations] (constant, so ES2's loop rules hold), with an inner `if (index >= count) break` for the + * dynamic [count]. [body] runs **once** at trace time against a `float(_i)` index expression — its + * recorded statements become the loop body (not unrolled). Use for a variable sample count with a fixed + * ceiling (SDF marching, adaptive blur); use [unroll] when the count is a compile-time constant. + */ +@ExperimentalMirage +public fun loop(count: Float1, maxIterations: Int, body: (index: Float1) -> Unit) { + val trace = activeTrace() + val indexName = trace.freshName() + trace.loop(count.e, maxIterations, indexName) { + body(Float1(VarRef(indexName, ShaderType.Float1))) + } +} diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/internal/MirageCompiler.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/internal/MirageCompiler.kt index 4e808a62..c2d779ee 100644 --- a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/internal/MirageCompiler.kt +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/internal/MirageCompiler.kt @@ -22,13 +22,8 @@ import com.skydoves.cloudy.FilterShader import com.skydoves.cloudy.GeneratorShader import com.skydoves.cloudy.MirageParams import com.skydoves.cloudy.MirageShader - -/** - * Thrown when [MirageCompiler.lint] finds a kernel token that cannot be compiled or contradicts the - * kernel's [ShaderCategory]. Carries the offending token and the reason so the failure points at the - * exact source problem. - */ -internal class MirageLintException(message: String) : IllegalArgumentException(message) +import com.skydoves.cloudy.edsl.MirageDiagnosticCode +import com.skydoves.cloudy.edsl.MirageDiagnosticException /** * Lowers an authored [MirageShader] into a per-dialect [CompiledProgram]. Pure and side-effect-free: it @@ -73,10 +68,13 @@ internal object MirageCompiler { // Standard-uniform references are static scans over comment-stripped code in every path (raw // included). Each gates two things in lockstep: whether the uniform is declared in the emitted // source, and whether the node binds it. Android's RuntimeShader rejects a write to an undeclared - // uniform, so these must agree exactly. (usesTime additionally drives redraw scheduling.) - val usesResolution = code.contains(STD_RESOLUTION) - val usesTime = code.contains(STD_TIME) - val usesDensity = code.contains(STD_DENSITY) + // uniform, so these must agree exactly. (usesTime additionally drives redraw scheduling, so a false + // positive here is not just a spurious declaration — it leaves an unwanted per-frame invalidation + // loop running. A whole-identifier match, not a substring one, is what keeps an unrelated name like + // `mirageTimeScale` from tripping it.) + val usesResolution = referencesIdentifier(code, STD_RESOLUTION) + val usesTime = referencesIdentifier(code, STD_TIME) + val usesDensity = referencesIdentifier(code, STD_DENSITY) // A raw shader owns its full source (uniform declarations included) and is treated as Composite, // so it always samples content. Emit it verbatim: no preamble, no generated declarations, no wrap. @@ -134,28 +132,31 @@ internal object MirageCompiler { lintCode(stripComments(kernelSource), category) private fun lintCode(code: String, category: ShaderCategory) { - for (token in FORBIDDEN_TOKENS) { - if (code.contains(token)) { - throw MirageLintException( - "mirage kernel uses forbidden token '$token': it does not compile as a runtime shader " + - "(no derivatives / preprocessor / raw frag-coord are available).", - ) - } + // Preprocessor markers ('#version', '#') are not identifier characters, so a plain substring check + // cannot false-positive on an unrelated name; the derivative/frag-coord builtins are identifiers and + // go through the same whole-token match `referencesIdentifier` uses for the standard uniforms. + for (token in FORBIDDEN_PREPROCESSOR_TOKENS) { + if (code.contains(token)) throw forbiddenTokenException(token) + } + for (token in FORBIDDEN_IDENTIFIER_TOKENS) { + if (referencesIdentifier(code, token)) throw forbiddenTokenException(token) } when (category) { ShaderCategory.Colorize -> - if (code.contains(CONTENT_TOKEN)) { - throw MirageLintException( - "Colorize kernel references '$CONTENT_TOKEN': a point-wise kernel must read content " + - "only through its `src` argument. Use composite() to sample content.", + if (code.contains(CONTENT_EVAL_CALL)) { + throw MirageDiagnosticException( + MirageDiagnosticCode.CONTENT_IN_COLORIZE, + "Colorize kernel references '$CONTENT_TOKEN': a point-wise kernel must read content only through its `src` argument", + "use composite() to sample content", ) } ShaderCategory.Generate -> - if (code.contains(CONTENT_TOKEN)) { - throw MirageLintException( - "Generate overlay kernel references '$CONTENT_TOKEN': an overlay has no content " + - "sampler. Use composite() to sample content.", + if (code.contains(CONTENT_EVAL_CALL)) { + throw MirageDiagnosticException( + MirageDiagnosticCode.CONTENT_IN_COLORIZE, + "Generate overlay kernel references '$CONTENT_TOKEN': an overlay has no content sampler", + "use composite() to sample content", ) } @@ -163,6 +164,35 @@ internal object MirageCompiler { } } + private fun forbiddenTokenException(token: String): MirageDiagnosticException = + MirageDiagnosticException( + MirageDiagnosticCode.FORBIDDEN_TOKEN, + "mirage kernel uses forbidden token '$token': it does not compile as a runtime shader", + "no derivatives (fwidth/dFdx/dFdy), preprocessor (#), or raw frag-coord (sk_FragCoord) are available", + ) + + /** + * True if [code] uses the GLSL identifier [name] as a whole token, not merely as a substring of some + * other identifier (`mirageTime` must not match inside `mirageTimeScale`). GLSL identifier characters + * are `[A-Za-z0-9_]`, so a real reference has a non-identifier character (or a string boundary) on + * both sides. + */ + private fun referencesIdentifier(code: String, name: String): Boolean { + var from = 0 + while (true) { + val at = code.indexOf(name, from) + if (at == -1) return false + val before = at - 1 + val after = at + name.length + val boundaryBefore = before < 0 || !code[before].isIdentifierChar() + val boundaryAfter = after >= code.length || !code[after].isIdentifierChar() + if (boundaryBefore && boundaryAfter) return true + from = at + 1 + } + } + + private fun Char.isIdentifierChar(): Boolean = this == '_' || isLetterOrDigit() + /** * Strips `//` line comments and `/* */` block comments from shader source so the analysis scans * only live code. A minimal single-pass state machine - shader source has no string literals, so @@ -268,21 +298,33 @@ internal object MirageCompiler { /** The content child sampler name (both the generated declaration and the wrapper reference it). */ private const val CONTENT_TOKEN = "content" + /** + * The exact call form a content read takes in every kernel body (hand-written or eDSL-emitted, see + * [com.skydoves.cloudy.edsl.sampleContent]) — matched literally rather than a bare + * [CONTENT_TOKEN] substring so an unrelated uniform name like `contentOpacity` does not trip the + * Colorize/Generate content-access lint. + */ + private const val CONTENT_EVAL_CALL = "content.eval(" + /** Colorize wrapper: sample content once at the fragment coord and hand the pixel to the kernel. */ private const val COLORIZE_MAIN_WRAPPER = "half4 main(float2 xy) { return kernel(xy, content.eval(xy)); }" + /** Preprocessor directives a runtime shader rejects — not identifiers, so a substring match is exact. */ + private val FORBIDDEN_PREPROCESSOR_TOKENS = listOf( + "#version", + "#", + ) + /** - * Tokens that never compile in a runtime shader (any category). fwidth/dFdx/dFdy are derivative - * functions AGSL lacks; '#version' / '#' are preprocessor directives the runtime rejects; - * sk_FragCoord is the raw builtin the wrapper's `xy` argument replaces. + * Builtins/functions a runtime shader lacks. Identifiers, so they are matched whole-token (via + * [referencesIdentifier]) rather than by substring — otherwise a kernel name like `dFdxScale` would + * be rejected for a function it never calls. */ - private val FORBIDDEN_TOKENS = listOf( + private val FORBIDDEN_IDENTIFIER_TOKENS = listOf( "fwidth", "dFdx", "dFdy", - "#version", - "#", "sk_FragCoord", ) } diff --git a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/internal/MiragePreamble.kt b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/internal/MiragePreamble.kt index 800326a4..d609de53 100644 --- a/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/internal/MiragePreamble.kt +++ b/cloudy/src/commonMain/kotlin/com/skydoves/cloudy/internal/MiragePreamble.kt @@ -35,6 +35,14 @@ package com.skydoves.cloudy.internal * code change. */ +/** + * The names of the helper functions the preamble text above defines. A user-authored `shaderFunction` + * that reused one of these would silently shadow the preamble helper, so [MirageReservedNames] pulls + * them in from this single source rather than re-listing the strings. + */ +internal val PREAMBLE_HELPER_NAMES: Set = + setOf("boxRoundedSDF", "lensNormalDirection", "toBrightness", "processColor") + /** AGSL lens helpers + shared consts a Composite / Generate kernel may call. */ internal const val MIRAGE_PREAMBLE_HELPERS_AGSL: String = """ const float SMOOTH_EDGE_PX = 1.5; diff --git a/cloudy/src/commonTest/kotlin/com/skydoves/cloudy/MirageCompilerTest.kt b/cloudy/src/commonTest/kotlin/com/skydoves/cloudy/MirageCompilerTest.kt index 0254496a..b0829767 100644 --- a/cloudy/src/commonTest/kotlin/com/skydoves/cloudy/MirageCompilerTest.kt +++ b/cloudy/src/commonTest/kotlin/com/skydoves/cloudy/MirageCompilerTest.kt @@ -18,13 +18,13 @@ package com.skydoves.cloudy import androidx.compose.ui.graphics.Color +import com.skydoves.cloudy.edsl.MirageDiagnosticException import com.skydoves.cloudy.internal.CHROMATIC_KERNEL_AGSL import com.skydoves.cloudy.internal.DUOTONE_KERNEL_AGSL import com.skydoves.cloudy.internal.DUOTONE_KERNEL_SKSL import com.skydoves.cloudy.internal.Dialect import com.skydoves.cloudy.internal.FOIL_KERNEL_AGSL import com.skydoves.cloudy.internal.MirageCompiler -import com.skydoves.cloudy.internal.MirageLintException import com.skydoves.cloudy.internal.SPECULAR_KERNEL_AGSL import com.skydoves.cloudy.internal.ShaderCategory import io.kotest.assertions.throwables.shouldNotThrowAny @@ -198,6 +198,24 @@ internal class MirageCompilerTest : program.usesDensity.shouldBe(false) } + // Regression: `code.contains("mirageTime")` used to match inside an unrelated identifier like + // `mirageTimeScale`, wrongly declaring the uniform and — since usesTime also drives redraw + // scheduling — leaving an unwanted per-frame invalidation loop running for a kernel that never + // reads the clock. + test("a uniform name that merely contains a standard uniform's name does not trigger it") { + val kernel = """ + half4 main(float2 xy) { return half4(half(mirageTimeScale), 0.0, 0.0, 1.0); } + """.trimIndent() + + val program = MirageCompiler.compile( + MirageShader.generate("shadowed", ::EmptyParams, kernel, kernel), + Dialect.Agsl, + ) + + program.usesTime.shouldBe(false) + program.source.shouldNotContain("uniform float mirageTime;") + } + // Android's RuntimeShader throws IllegalArgumentException if the node binds a standard uniform // the shader never declared, so a kernel that names no standard uniform must report every uses* // flag false and the node must bind none. @@ -256,14 +274,14 @@ internal class MirageCompilerTest : context("lint rejects tokens that cannot compile") { test("fwidth is rejected") { - val e = shouldThrow { + val e = shouldThrow { MirageCompiler.lint("half4 main() { return half4(fwidth(x)); }", ShaderCategory.Composite) } e.message.shouldContain("fwidth") } test("a #version directive is rejected") { - val e = shouldThrow { + val e = shouldThrow { MirageCompiler.lint( "#version 300 es\nhalf4 main() { return half4(0.0); }", ShaderCategory.Composite, @@ -273,18 +291,29 @@ internal class MirageCompilerTest : } test("dFdx / sk_FragCoord are rejected") { - shouldThrow { + shouldThrow { MirageCompiler.lint("float d = dFdx(x);", ShaderCategory.Composite) } - shouldThrow { + shouldThrow { MirageCompiler.lint("float2 c = sk_FragCoord.xy;", ShaderCategory.Composite) } } + + // Regression: a substring match on `dFdx` used to reject a kernel that merely names a uniform + // like `dFdxScale`, even though it never calls the (unavailable) derivative function. + test("a name that merely contains a forbidden builtin's name is not rejected") { + shouldNotThrowAny { + MirageCompiler.lint( + "half4 main() { return half4(half(dFdxScale)); }", + ShaderCategory.Composite, + ) + } + } } context("lint enforces the content-access category contract") { test("a Colorize kernel that references content is rejected") { - val e = shouldThrow { + val e = shouldThrow { MirageCompiler.lint( "half4 kernel(float2 p, half4 src) { return content.eval(p); }", ShaderCategory.Colorize, @@ -295,7 +324,7 @@ internal class MirageCompilerTest : } test("a Generate kernel that references content is rejected") { - val e = shouldThrow { + val e = shouldThrow { MirageCompiler.lint( "half4 main(float2 xy) { return content.eval(xy); }", ShaderCategory.Generate, @@ -312,6 +341,23 @@ internal class MirageCompilerTest : ) } } + + // Regression: `code.contains("content")` used to match a uniform name like `contentOpacity`, + // wrongly rejecting a Colorize/Generate kernel that never actually samples content. + test( + "a uniform name that merely contains 'content' does not trip the Colorize/Generate lint", + ) { + shouldNotThrowAny { + MirageCompiler.lint( + "half4 kernel(float2 p, half4 src) { return src * half(contentOpacity); }", + ShaderCategory.Colorize, + ) + MirageCompiler.lint( + "half4 main(float2 xy) { return half4(half(contentOpacity)); }", + ShaderCategory.Generate, + ) + } + } } context("carried kernels compile through the pipeline") { diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/MirageChromaticRasterTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/MirageChromaticRasterTest.kt index e33dcafc..3576d6b5 100644 --- a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/MirageChromaticRasterTest.kt +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/MirageChromaticRasterTest.kt @@ -272,38 +272,73 @@ private fun renderRidgeProbe( } /** - * Rewrites the chromatic superellipse bevel block in [source] to a box-SDF construction + * Rewrites the chromatic superellipse bevel field in [source] to a box-SDF construction * (`depthIn = max(-sdf, 0)` depth normalized by minHalf + a soft-min interior direction). Rendering * this contrasting field lets the guard assert the box construction dumps more diagonal energy, so the - * comparison cannot pass vacuously. Anchored on the bevel block's own source markers. + * comparison cannot pass vacuously. + * + * The eDSL emitter now hoists every shared subexpression into a flat, size-ordered `_tN` list rather + * than authoring-ordered `let` locals, so the superellipse computation is no longer a contiguous + * block to excise. Instead this locates the two temps that feed the bevel normal — `cDir` (the tangent + * direction) and `n_cos` (`1 - depth`) — by the distinctive shape of their initializers, then + * overwrites those two declarations with a box construction (recomputing the geometry from the + * uniforms, which are in scope). The temps the old superellipse init referenced are left as unused + * declarations; the shader compiler drops them. */ private fun toOldBoxBevel(source: String): String { - val startAnchor = " float2 q = abs(p) / max(halfDim, float2(1.0));" - val endAnchor = " float n_cos = 1.0 - t;" - val start = source.indexOf(startAnchor) - val end = source.indexOf(endAnchor) - require(start >= 0 && end > start) { - "superellipse block markers not found — update toOldBoxBevel" - } - val head = source.substring(0, start) - val tail = source.substring(end + endAnchor.length) - val boxBlock = """ - float2 d2 = abs(p) - halfDim + float2(r); - float2 s2 = float2(p.x >= 0.0 ? 1.0 : -1.0, p.y >= 0.0 ? 1.0 : -1.0); - float2 cDir; - if (max(d2.x, d2.y) > 0.0) { - cDir = s2 * normalize(max(d2, 0.0)); - } else { - float w = clamp(0.5 + 0.5 * (d2.x - d2.y) / SEAM_BLEND_PX, 0.0, 1.0); - cDir = normalize(float2(s2.x * w, s2.y * (1.0 - w)) + float2(0.0, 1.0e-4)); - } - float depthIn = max(-sdf, 0.0); - float t = clamp(depthIn / max(minHalf, 1.0), 0.0, 1.0); - float n_cos = 1.0 - t; - """.trimIndent() - return head + boxBlock + tail + // cDir: hoisting lifts the `float2(1.0E-4, 1.0E-4)` epsilon into its own temp, so chain from that + // epsilon → the `(dir + eps)` temp → the `normalize(...)` temp. cLightVec's normalize takes the bare + // `iLight` (no epsilon), so this path is unambiguous. + val epsName = tempWhoseInit(source, Regex("""^float2\(1\.0E-4, 1\.0E-4\)$""")) + val cDirArg = tempWhoseInit(source, Regex("""\+ ${Regex.escape(epsName)}\)$""")) + val cDirName = tempWhoseInit(source, Regex("""^normalize\(${Regex.escape(cDirArg)}\)$""")) + // n_cos = `1 - t` where `t = clamp(pow(..., 0.25), 0, 1)`; the `pow(_, 0.25)` (= 1/SE_POW) is the + // superellipse's fingerprint, so chain from it to the clamp to the `(1.0 - clamp)` that names n_cos. + val fName = tempWhoseInit(source, Regex("""^pow\([^,]+, 0\.25\)$""")) + val tName = tempWhoseInit(source, Regex("""^clamp\(${Regex.escape(fName)}, 0\.0, 1\.0\)$""")) + val nCosName = tempWhoseInit(source, Regex("""^\(1\.0 - ${Regex.escape(tName)}\)$""")) + + // Recompute the lens geometry from the uniforms (all in scope) so the box block needs no other temp. + val prelude = """ + float2 pBox = xy - lensCenter; + float2 halfDimBox = lensSize * 0.5; + float rBox = min(cornerRadius, min(halfDimBox.x, halfDimBox.y)); + float sdfBox = boxRoundedSDF(pBox, halfDimBox, rBox); + float2 d2Box = abs(pBox) - halfDimBox + float2(rBox); + float2 s2Box = float2(pBox.x >= 0.0 ? 1.0 : -1.0, pBox.y >= 0.0 ? 1.0 : -1.0); + float depthInBox = max(-sdfBox, 0.0); + float tBox = clamp(depthInBox / max(min(halfDimBox.x, halfDimBox.y), 1.0), 0.0, 1.0); + """.trimIndent().prependIndent(" ") + + // Insert the prelude just after `main(...) {` so its box geometry precedes every `_tN` use. + val open = source.indexOf('{', source.indexOf("half4 main(")) + 1 + val withPrelude = source.substring(0, open) + "\n" + prelude + source.substring(open) + + // Overwrite the two bevel temps' initializers with the box construction (soft-min interior dir + box + // depth). A conditional needs a statement, so declare cDir up top and assign it here via a helper. + val boxCDir = "(max(d2Box.x, d2Box.y) > 0.0 ? s2Box * normalize(max(d2Box, 0.0)) : " + + "normalize(float2(s2Box.x * clamp(0.5 + 0.5 * (d2Box.x - d2Box.y) / SEAM_BLEND_PX, 0.0, 1.0)," + + " s2Box.y * (1.0 - clamp(0.5 + 0.5 * (d2Box.x - d2Box.y) / SEAM_BLEND_PX, 0.0, 1.0))) + " + + "float2(0.0, 1.0e-4)))" + return withPrelude + .let { replaceInit(it, cDirName, boxCDir) } + .let { replaceInit(it, nCosName, "(1.0 - tBox)") } } +private val hoistedTempDeclarationPattern = Regex("""\b(_t\d+) = ([^;]+);""") + +/** The temp name declared as ` = ;` whose `` matches [initPattern]. */ +private fun tempWhoseInit(source: String, initPattern: Regex): String { + val decl = hoistedTempDeclarationPattern.findAll(source) + .firstOrNull { initPattern.containsMatchIn(it.groupValues[2].trim()) } + ?: error("no temp with init matching /$initPattern/ found — update toOldBoxBevel") + return decl.groupValues[1] +} + +/** Rewrites the RHS of temp [name]'s declaration to [init], keeping its declared type and name. */ +private fun replaceInit(source: String, name: String, init: String): String = + source.replace(Regex("""(\b${Regex.escape(name)}) = [^;]+;"""), "$1 = $init;") + /** * Broad diagonal-ridge metric. The box-bevel X is a *wide smooth ridge* whose luma gradient points * across the diagonal over a large area, not a 1px crease. This takes the central-difference luma diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageBranchTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageBranchTest.kt new file mode 100644 index 00000000..075fbe22 --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageBranchTest.kt @@ -0,0 +1,132 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.MirageShader +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.doubles.shouldBeLessThanOrEqual +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import org.jetbrains.skia.RuntimeEffect +import org.jetbrains.skia.RuntimeShaderBuilder +import org.jetbrains.skia.Shader + +private const val RASTER = 32 + +/** + * F7 value-branches. [branch]…[Else] is a 2-way if-expression; [When] is an n-way first-match-wins + * when-expression lowering to nested `if/else`. A missing `otherwise` is a trace diagnostic; mismatched + * arm types are a diagnostic. Verified on emitted text and by rasterizing a `When`-built kernel. + */ +internal class MirageBranchTest : + FunSpec({ + + test("branch...Else emits an if/else that writes one temp on both paths") { + val kernel = MirageShader.generate("branch2", ::EmptyParams) { xy -> + val tint = branch(xy.x greaterThan 16f) { + half3(1f, 0f, 0f) + } Else { + half3(0f, 0f, 1f) + } + half4(tint, half(1f)) + }.agsl + + kernel shouldContain "if (" + kernel shouldContain "} else {" + } + + test("When emits nested if/else for n cases + otherwise, first-match-wins order") { + val kernel = MirageShader.generate("whenN", ::EmptyParams) { xy -> + val zone = When { + (xy.x greaterThan 24f) then { half3(1f, 0f, 0f) } + (xy.x greaterThan 8f) then { half3(0f, 1f, 0f) } + otherwise { half3(0f, 0f, 1f) } + } + half4(zone, half(1f)) + }.agsl + + // Two case conditions nested via else, then the otherwise else — nested `if/else` chain. + Regex("if \\(").findAll(kernel).count() shouldBe 2 + kernel shouldContain "} else {" + } + + test("a When without otherwise fails with WHEN_WITHOUT_OTHERWISE") { + val ex = shouldThrow { + MirageShader.generate("noOtherwise", ::EmptyParams) { xy -> + val zone = When { + (xy.x greaterThan 8f) then { half3(1f, 0f, 0f) } + // No otherwise — the block ends in a stray value instead. + half3(0f, 0f, 0f) + } + half4(zone, half(1f)) + } + } + ex.code shouldBe MirageDiagnosticCode.WHEN_WITHOUT_OTHERWISE + } + + test("branch arms of different types fail with BRANCH_TYPE_MISMATCH") { + val ex = shouldThrow { + MirageShader.generate("mismatch", ::EmptyParams) { xy -> + // Arms return different value types (Half3 vs Float3): Kotlin infers T = ShaderValue, and the + // trace-time arm-type check rejects the disagreement before the result is wrapped. + branch(xy.x greaterThan 8f) { + half3(1f, 0f, 0f) + } Else { + float3(0f, 0f, 1f) + } + half4(0f) // unreachable: the arm-type mismatch above throws during the trace + } + } + ex.code shouldBe MirageDiagnosticCode.BRANCH_TYPE_MISMATCH + } + + test("a When-built kernel rasterizes like its hand-written if/else equivalent") { + meanAbsDiff( + rasterize(edslWhenShader(), RASTER), + rasterize(handRolledWhenShader(), RASTER), + ).shouldBeLessThanOrEqual(0.5) + } + }) + +private fun edslWhenShader(): Shader { + val kernel = MirageShader.generate("whenRaster", ::EmptyParams) { xy -> + val zone = When { + (xy.x greaterThan 24f) then { half3(1f, 0f, 0f) } + (xy.x greaterThan 8f) then { half3(0f, 1f, 0f) } + otherwise { half3(0f, 0f, 1f) } + } + half4(zone, half(1f)) + }.agsl + return RuntimeShaderBuilder(RuntimeEffect.makeForShader(kernel)).makeShader() +} + +private fun handRolledWhenShader(): Shader = RuntimeShaderBuilder( + RuntimeEffect.makeForShader( + """ + half4 main(float2 xy) { + half3 zone; + if ((xy.x > 24.0)) { zone = half3(1.0, 0.0, 0.0); } + else { if ((xy.x > 8.0)) { zone = half3(0.0, 1.0, 0.0); } + else { zone = half3(0.0, 0.0, 1.0); } } + return half4(zone, half(1.0)); + } + """.trimIndent(), + ), +).makeShader() diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageDiagnosticsTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageDiagnosticsTest.kt new file mode 100644 index 00000000..48bd5ded --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageDiagnosticsTest.kt @@ -0,0 +1,50 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.internal.MirageCompiler +import com.skydoves.cloudy.internal.ShaderCategory +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +/** + * Verifies mirage failures carry a stable [MirageDiagnosticCode] a tool can branch on, instead of only + * a free-text message. Asserts on `.code`, never on wording. + */ +internal class MirageDiagnosticsTest : + FunSpec({ + + test("a Colorize kernel that references content fails with CONTENT_IN_COLORIZE") { + val ex = shouldThrow { + MirageCompiler.lint( + "half4 kernel(float2 p, half4 src) { return content.eval(p); }", + ShaderCategory.Colorize, + ) + } + ex.code shouldBe MirageDiagnosticCode.CONTENT_IN_COLORIZE + } + + test("a forbidden derivative token fails with FORBIDDEN_TOKEN") { + val ex = shouldThrow { + MirageCompiler.lint("half4 main() { return half4(fwidth(x)); }", ShaderCategory.Composite) + } + ex.code shouldBe MirageDiagnosticCode.FORBIDDEN_TOKEN + } + }) diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslChromaticTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslChromaticTest.kt new file mode 100644 index 00000000..50109632 --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslChromaticTest.kt @@ -0,0 +1,198 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.MirageShaders +import com.skydoves.cloudy.internal.Dialect +import com.skydoves.cloudy.internal.MirageProgramCache +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.doubles.shouldBeGreaterThan +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.jetbrains.skia.RuntimeEffect +import org.jetbrains.skia.RuntimeShaderBuilder +import org.jetbrains.skia.Shader + +private const val RASTER = 64 + +/** + * Raster-parity gate for [MirageShaders.Chromatic] — the last of the four bundled kernels ported + * through the eDSL, and the only one with a `float4`-typed uniform read via `.xyz` swizzle + * ([Float4], `chromaticKRGB`). One kernel program backs every named look (`OilSlick`, `SoapBubble`, + * `MetallicFoil`, `Pearl`) purely through uniform defaults; this test binds one look's values (the + * `Chromatic` defaults) since the source is identical regardless of which look's defaults a draw uses. + */ +internal class MirageEdslChromaticTest : + FunSpec({ + + test("MirageShaders.Chromatic compiles and rasterizes through the real skiko RuntimeEffect") { + meanAbsDiff( + rasterize(buildEdslChromaticShader(), RASTER), + rasterize(handRolledChromaticShader(), RASTER), + ) shouldBe 0.0 + } + + test("OilSlick, SoapBubble, MetallicFoil, and Pearl share one compiled kernel program") { + val oil = MirageProgramCache.obtain(MirageShaders.OilSlick, Dialect.Sksl).shouldNotBeNull() + val soap = MirageProgramCache.obtain(MirageShaders.SoapBubble, Dialect.Sksl).shouldNotBeNull() + val foil = MirageProgramCache.obtain( + MirageShaders.MetallicFoil, + Dialect.Sksl, + ).shouldNotBeNull() + val pearl = MirageProgramCache.obtain(MirageShaders.Pearl, Dialect.Sksl).shouldNotBeNull() + + oil.compiled.source shouldBe soap.compiled.source + oil.compiled.source shouldBe foil.compiled.source + oil.compiled.source shouldBe pearl.compiled.source + } + + test("the eDSL-traced thin-film tint actually changes the content, not a no-op") { + val chromatic = rasterize(buildEdslChromaticShader(), RASTER) + val baseline = rasterize(contentShader(), RASTER) + meanAbsDiff(chromatic, baseline).shouldBeGreaterThan(1.0) + } + }) + +private fun buildEdslChromaticShader(): Shader { + val cached = MirageProgramCache.obtain(MirageShaders.Chromatic, Dialect.Sksl).shouldNotBeNull() + return bindChromaticUniforms( + RuntimeShaderBuilder(RuntimeEffect.makeForShader(cached.compiled.source)), + ) +} + +/** + * The pre-eDSL kernel text (what `CHROMATIC_KERNEL_SKSL` used to read before this port), kept here + * only as the golden reference for the raster-parity test above — not reachable from production code. + */ +private fun handRolledChromaticShader(): Shader { + val source = """ + const float SMOOTH_EDGE_PX = 1.5; + const float CHROMA_OPD_BASE = 0.10; + const float CHROMA_THICK_MIX = 0.55; + const float CHROMA_RIM_POW = 3.0; + const float CHROMA_SE_POW = 4.0; + + float boxRoundedSDF(float2 p, float2 halfDim, float r) { + float2 d = abs(p) - halfDim + float2(r); + float exterior = length(max(d, 0.0)); + float interior = min(max(d.x, d.y), 0.0); + return exterior + interior - r; + } + + uniform float2 lensCenter; + uniform float2 lensSize; + uniform float cornerRadius; + uniform float2 iLight; + uniform float chromaticIntensity; + uniform float chromaticGain; + uniform float4 chromaticKRGB; + uniform float chromaticFloor; + uniform float chromaticWashout; + uniform float chromaticModulate; + uniform float chromaticRimBoost; + uniform float chromaticPoolFrac; + uniform shader content; + + half4 main(float2 xy) { + float2 halfDim = lensSize * 0.5; + float r = min(cornerRadius, min(halfDim.x, halfDim.y)); + + float2 p = xy - lensCenter; + float sdf = boxRoundedSDF(p, halfDim, r); + + if (sdf > SMOOTH_EDGE_PX) { + return content.eval(xy); + } + + half4 pixel = content.eval(xy); + + float minHalf = min(halfDim.x, halfDim.y); + float2 cLightVec = normalize(iLight); + float2 q = abs(p) / max(halfDim, float2(1.0)); + float2 s2 = float2(p.x >= 0.0 ? 1.0 : -1.0, p.y >= 0.0 ? 1.0 : -1.0); + float f = pow(pow(q.x, CHROMA_SE_POW) + pow(q.y, CHROMA_SE_POW), 1.0 / CHROMA_SE_POW); + float2 cDir = normalize( + s2 * float2(CHROMA_SE_POW * pow(q.x, CHROMA_SE_POW - 1.0) / max(halfDim.x, 1.0), + CHROMA_SE_POW * pow(q.y, CHROMA_SE_POW - 1.0) / max(halfDim.y, 1.0)) + + float2(1.0e-4, 1.0e-4)); + float t = clamp(f, 0.0, 1.0); + float n_cos = 1.0 - t; + float n_sin = sqrt(max(1.0 - n_cos * n_cos, 0.0)); + float3 cN = normalize(float3(cDir * n_cos, n_sin + 1.0e-3)); + float3 cL = normalize(float3(cLightVec, 0.55)); + + float cosT = clamp(dot(cN, cL), 0.0, 1.0); + float thick = 1.0 - n_cos; + float ringTerm = thick / max(1.0 - 0.6 * cosT, 1.0e-2); + float opdDrive = mix(cosT, ringTerm, CHROMA_THICK_MIX); + float opd = opdDrive * chromaticGain + CHROMA_OPD_BASE; + float3 interf = 0.5 + 0.5 * cos(6.28318530718 * opd * chromaticKRGB.xyz); + float3 metalRGB = chromaticFloor + (1.0 - chromaticFloor) * interf; + float sat = exp(-opd * chromaticWashout); + float3 thinFilm = mix(float3(1.0), metalRGB, clamp(sat, 0.0, 1.0)); + float rimBoost = chromaticRimBoost * pow(clamp(thick, 0.0, 1.0), CHROMA_RIM_POW); + float3 chromaRGB = mix(thinFilm, float3(1.0), clamp(rimBoost, 0.0, 1.0)); + + float2 cFocal = cLightVec * (minHalf * 0.55); + float cPoolR = max(minHalf * chromaticPoolFrac, 1.0); + float cPool = 1.0 - smoothstep(0.0, cPoolR, length(p - cFocal)); + float poolNorm = clamp(cPool * cPool, 0.0, 1.0); + float chroma = chromaticIntensity * mix(1.0, poolNorm, clamp(chromaticModulate, 0.0, 1.0)); + + half cChroma = half(clamp(chroma, 0.0, 1.0)); + half3 cChromaRGB = half3(chromaRGB) * cChroma; + half3 cOnWhite = half3(chromaRGB); + half3 cOnSrc = half3(1.0) - (half3(1.0) - pixel.rgb) * (half3(1.0) - cChromaRGB); + pixel.rgb = mix(cOnWhite, cOnSrc, pixel.a); + pixel.a = max(pixel.a, cChroma); + + float alpha = 1.0 - smoothstep(-SMOOTH_EDGE_PX * 0.5, SMOOTH_EDGE_PX * 0.5, sdf); + half4 bg = content.eval(xy); + return mix(bg, pixel, alpha); + } + """.trimIndent() + return bindChromaticUniforms(RuntimeShaderBuilder(RuntimeEffect.makeForShader(source))) +} + +/** The default Chromatic look both shaders under test are compared at (lens covers the raster). */ +private fun bindChromaticUniforms(builder: RuntimeShaderBuilder): Shader { + builder.uniform("lensCenter", RASTER / 2f, RASTER / 2f) + builder.uniform("lensSize", RASTER.toFloat(), RASTER.toFloat()) + builder.uniform("cornerRadius", 8f) + builder.uniform("iLight", -1f, -1f) + builder.uniform("chromaticIntensity", 0.6f) + builder.uniform("chromaticGain", 3.0f) + builder.uniform("chromaticKRGB", 1f, 1.18f, 1.42f, 0f) + builder.uniform("chromaticFloor", 0.12f) + builder.uniform("chromaticWashout", 0.16f) + builder.uniform("chromaticModulate", 1f) + builder.uniform("chromaticRimBoost", 0f) + builder.uniform("chromaticPoolFrac", 0.7f) + builder.child("content", contentShader()) + return builder.makeShader() +} + +private fun contentShader(): Shader = RuntimeEffect.makeForShader( + """ + half4 main(float2 xy) { + float2 uv = xy / float2($RASTER.0, $RASTER.0); + return half4(half(uv.x), half(uv.y), half(1.0 - uv.x), 1.0); + } + """.trimIndent(), +).let { RuntimeShaderBuilder(it).makeShader() } diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslDuotoneTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslDuotoneTest.kt new file mode 100644 index 00000000..cf33b624 --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslDuotoneTest.kt @@ -0,0 +1,101 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.MirageShaders +import com.skydoves.cloudy.internal.Dialect +import com.skydoves.cloudy.internal.MirageProgramCache +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.jetbrains.skia.RuntimeEffect +import org.jetbrains.skia.RuntimeShaderBuilder +import org.jetbrains.skia.Shader + +private const val RASTER = 32 + +/** + * MVP proof that [MirageShaders.Duotone] — now traced through the eDSL (see [traceDuotone] / + * [emitColorizeKernel]) instead of authored as two hand-written AGSL/SkSL strings — still compiles + * through the real skiko [RuntimeEffect] and rasterizes byte-for-byte identically to a build of the + * old hand-written kernel text. A text diff would catch a wording change, but only a pixel diff proves + * the *emitted GLSL* still means the same thing. [handRolledDuotoneShader] is a real color blend (not + * identity), so this one byte-identity assertion also rules out a regression to a passthrough no-op. + */ +internal class MirageEdslDuotoneTest : + FunSpec({ + + test("MirageShaders.Duotone compiles and rasterizes through the real skiko RuntimeEffect") { + meanAbsDiff( + rasterize(buildEdslDuotoneShader(), RASTER), + rasterize(handRolledDuotoneShader(), RASTER), + ) shouldBe 0.0 + } + }) + +/** Binds [MirageShaders.Duotone]'s eDSL-traced, program-cache-compiled source to a live shader. */ +private fun buildEdslDuotoneShader(): Shader { + val cached = MirageProgramCache.obtain(MirageShaders.Duotone, Dialect.Sksl).shouldNotBeNull() + return bindDuotoneUniforms( + RuntimeShaderBuilder(RuntimeEffect.makeForShader(cached.compiled.source)), + ) +} + +/** + * The pre-eDSL kernel text (what `DUOTONE_KERNEL_SKSL` used to read before this MVP), kept here only + * as the golden reference for the raster-parity test above — not reachable from production code. + */ +private fun handRolledDuotoneShader(): Shader { + // layout(color) uniforms and the kernel/main split match MirageCompiler.assemble's actual output + // shape byte-for-byte (see cached.compiled.source in buildEdslDuotoneShader) — only the luma+mix + // expression is spelled with the old intermediate half/half3 locals instead of the eDSL's + // fully-inlined form, which is exactly the thing this test is proving compiles to the same pixels. + val source = """ + layout(color) uniform float4 shadow; + layout(color) uniform float4 highlight; + uniform float amount; + uniform shader content; + + half4 kernel(float2 p, half4 src) { + half g = half(dot(src.rgb, half3(0.2126, 0.7152, 0.0722))); + half3 dz = mix(half3(shadow.rgb), half3(highlight.rgb), g); + return half4(mix(src.rgb, dz, half(amount)), src.a); + } + half4 main(float2 xy) { return kernel(xy, content.eval(xy)); } + """.trimIndent() + return bindDuotoneUniforms(RuntimeShaderBuilder(RuntimeEffect.makeForShader(source))) +} + +/** The one non-default Duotone look both shaders under test are compared at. */ +private fun bindDuotoneUniforms(builder: RuntimeShaderBuilder): Shader { + builder.uniform("shadow", 0.106f, 0.106f, 0.227f, 1f) + builder.uniform("highlight", 1f, 0.910f, 0.780f, 1f) + builder.uniform("amount", 1f) + builder.child("content", contentShader()) + return builder.makeShader() +} + +private fun contentShader(): Shader = RuntimeEffect.makeForShader( + """ + half4 main(float2 xy) { + float2 uv = xy / float2($RASTER.0, $RASTER.0); + return half4(half(uv.x), half(uv.y), half(1.0 - uv.x), 1.0); + } + """.trimIndent(), +).let { RuntimeShaderBuilder(it).makeShader() } diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslFoilTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslFoilTest.kt new file mode 100644 index 00000000..4c1e8f2c --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslFoilTest.kt @@ -0,0 +1,159 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.MirageShaders +import com.skydoves.cloudy.internal.Dialect +import com.skydoves.cloudy.internal.MirageProgramCache +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.doubles.shouldBeGreaterThan +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.jetbrains.skia.RuntimeEffect +import org.jetbrains.skia.RuntimeShaderBuilder +import org.jetbrains.skia.Shader + +private const val RASTER = 64 + +/** + * Raster-parity gate for [MirageShaders.Foil] — the first eDSL-ported kernel with control flow (an + * early-return guard, [EarlyReturn]), a Generate/`main(float2 xy)` signature, and a user-defined helper + * function ([HelperFunction], `foilHash`). Proves the eDSL-traced source rasterizes byte-for-byte + * identically to the pre-eDSL hand-written kernel text at a fixed, non-zero `mirageTime` (so the + * time-driven shimmer terms are exercised, not just the `t=0` path). + */ +internal class MirageEdslFoilTest : + FunSpec({ + + test("MirageShaders.Foil compiles and rasterizes through the real skiko RuntimeEffect") { + meanAbsDiff( + rasterize(buildEdslFoilShader(), RASTER), + rasterize(handRolledFoilShader(), RASTER), + ) shouldBe 0.0 + } + + test("the eDSL-traced early-return guard actually masks pixels outside the lens") { + // A raster larger than the lens must have transparent corners (the sdf > SMOOTH_EDGE_PX guard). + val pixels = rasterize(buildEdslFoilShader(), RASTER) + val cornerAlpha = pixels[3].toInt() and 0xFF // top-left pixel, ARGB_8888 alpha byte + cornerAlpha shouldBe 0 + + val centerIndex = ((RASTER / 2) * RASTER + RASTER / 2) * 4 + val centerAlpha = pixels[centerIndex + 3].toInt() and 0xFF + centerAlpha.toDouble().shouldBeGreaterThan(0.0) + } + }) + +/** Binds [MirageShaders.Foil]'s eDSL-traced, program-cache-compiled source to a live shader. */ +private fun buildEdslFoilShader(): Shader { + val cached = MirageProgramCache.obtain(MirageShaders.Foil, Dialect.Sksl).shouldNotBeNull() + return bindFoilUniforms(RuntimeShaderBuilder(RuntimeEffect.makeForShader(cached.compiled.source))) +} + +/** + * The pre-eDSL kernel text (what `FOIL_KERNEL_SKSL` used to read before this port), kept here only as + * the golden reference for the raster-parity test above — not reachable from production code. + */ +private fun handRolledFoilShader(): Shader { + val source = """ + const float SMOOTH_EDGE_PX = 1.5; + + float boxRoundedSDF(float2 p, float2 halfDim, float r) { + float2 d = abs(p) - halfDim + float2(r); + float exterior = length(max(d, 0.0)); + float interior = min(max(d.x, d.y), 0.0); + return exterior + interior - r; + } + + uniform float mirageTime; + uniform float2 lensCenter; + uniform float2 lensSize; + uniform float cornerRadius; + uniform float2 iLight; + uniform float foilBands; + uniform float foilPhase; + uniform float chromaticGain; + uniform float sparkleDensity; + uniform float sparkleAmplitude; + + float foilHash(float2 c) { + return fract(sin(dot(c, float2(127.1, 311.7))) * 43758.5453); + } + + half4 main(float2 xy) { + float2 halfDim = lensSize * 0.5; + float r = min(cornerRadius, min(halfDim.x, halfDim.y)); + + float2 p = xy - lensCenter; + float sdf = boxRoundedSDF(p, halfDim, r); + + if (sdf > SMOOTH_EDGE_PX) { + return half4(0.0); + } + + float minHalf = min(halfDim.x, halfDim.y); + float2 cLightVec = normalize(iLight); + float2 pNorm = p / minHalf; + float t = clamp(max(-sdf, 0.0) / max(minHalf, 1.0), 0.0, 1.0); + + float along = dot(pNorm, cLightVec); + float glare = smoothstep(0.2, 1.0, along) * (1.0 - t); + float dome = (1.0 - smoothstep(0.0, 1.0, length(pNorm))) * 0.5; + + float hueF = fract(along * foilBands + foilPhase + 0.05 * mirageTime); + float3 hsv = clamp( + abs(fract(float3(hueF) + float3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0) - 1.0, + 0.0, 1.0); + float opd = (0.5 + 0.5 * t) * chromaticGain; + float3 film = 0.5 + 0.5 * cos(6.28318530718 * opd * float3(1.0, 1.18, 1.42)); + float3 rainbow = mix(hsv, film, 0.4); + + float2 cell = floor(pNorm * sparkleDensity); + float h = foilHash(cell); + float2 cellUv = fract(pNorm * sparkleDensity) - 0.5; + float d = length(cellUv); + float aa = clamp(sparkleDensity / max(minHalf, 1.0), 0.02, 0.25); + float dot0 = 1.0 - smoothstep(0.18 - aa, 0.18 + aa, d); + float twinkle = 0.5 + 0.5 * sin(6.2831853 * (h + 0.3 * mirageTime)); + float spark = step(0.78, h) * dot0 * twinkle * sparkleAmplitude; + + float lum = clamp(glare + dome, 0.0, 1.0); + float3 rgb = rainbow * lum + float3(spark); + float a = clamp(lum + spark, 0.0, 1.0); + float mask = 1.0 - smoothstep(-SMOOTH_EDGE_PX * 0.5, SMOOTH_EDGE_PX * 0.5, sdf); + return half4(half3(rgb) * half(mask), half(a * mask)); + } + """.trimIndent() + return bindFoilUniforms(RuntimeShaderBuilder(RuntimeEffect.makeForShader(source))) +} + +/** The one non-default Foil look both shaders under test are compared at (lens covers the raster). */ +private fun bindFoilUniforms(builder: RuntimeShaderBuilder): Shader { + builder.uniform("mirageTime", 1.75f) + builder.uniform("lensCenter", RASTER / 2f, RASTER / 2f) + builder.uniform("lensSize", RASTER.toFloat(), RASTER.toFloat()) + builder.uniform("cornerRadius", 8f) + builder.uniform("iLight", -1f, -1f) + builder.uniform("foilBands", 5f) + builder.uniform("foilPhase", 0f) + builder.uniform("chromaticGain", 3.6f) + builder.uniform("sparkleDensity", 16f) + builder.uniform("sparkleAmplitude", 0.3f) + return builder.makeShader() +} diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslRainyWindowTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslRainyWindowTest.kt new file mode 100644 index 00000000..1b84186c --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslRainyWindowTest.kt @@ -0,0 +1,254 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.RainyWindowOptic +import com.skydoves.cloudy.internal.Dialect +import com.skydoves.cloudy.internal.MirageProgramCache +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.doubles.shouldBeLessThanOrEqual +import io.kotest.matchers.nulls.shouldNotBeNull +import org.jetbrains.skia.RuntimeEffect +import org.jetbrains.skia.RuntimeShaderBuilder +import org.jetbrains.skia.Shader + +private const val RASTER = 64 + +/** + * Raster-parity gate for [RainyWindowOptic.RainyWindow] — the "Heartfelt" rain-on-glass kernel ported + * through the eDSL. It is the widest port so far: six helper functions (three single-expression, three + * statement-body with mutable locals), a `mirageResolution` standard uniform, and a bilinear `wipeMask` + * texture read via [SampleTexture] (four un-merged taps). Proves the eDSL-traced + program-cache-compiled + * source rasterizes to within a hair of the original hand-written `RAINY_WINDOW_KERNEL` string when both + * are bound with identical uniforms and content/mask children. + * + * The eDSL rewrites the kernel structurally (write-swizzle mutations become full reassignments, blur + * accumulation reorders), so byte-identical *text* is not the gate — pixel parity is. A tiny tolerance + * absorbs float-reassociation rounding the restructuring can introduce; it is far below any visible or + * structural difference (a real regression moves it by orders of magnitude). + */ +internal class MirageEdslRainyWindowTest : + FunSpec({ + + test( + "RainyWindow rasterizes like the hand-written RAINY_WINDOW_KERNEL through skiko RuntimeEffect", + ) { + meanAbsDiff( + rasterize(buildEdslRainyWindowShader(), RASTER), + rasterize(handRolledRainyWindowShader(), RASTER), + ).shouldBeLessThanOrEqual(0.5) + } + }) + +private fun buildEdslRainyWindowShader(): Shader { + val cached = + MirageProgramCache.obtain(RainyWindowOptic.RainyWindow, Dialect.Sksl).shouldNotBeNull() + return bindRainyWindowUniforms( + RuntimeShaderBuilder(RuntimeEffect.makeForShader(cached.compiled.source)), + ) +} + +/** The pre-eDSL kernel text (the `RAINY_WINDOW_KERNEL` string), the golden reference for parity. */ +private fun handRolledRainyWindowShader(): Shader { + val source = """ + uniform float2 mirageResolution; + uniform float mirageTime; + uniform shader content; + uniform shader wipeMask; + uniform float maskSize; + uniform float introProgress; + uniform float rainAmount; + uniform float blurRadius; + uniform float fogAmount; + uniform float hazeStrength; + uniform float dropScale; + + float3 N13(float p) { + float3 p3 = fract(float3(p, p, p) * float3(0.1031, 0.11369, 0.13787)); + p3 += dot(p3, p3.yzx + 19.19); + return fract(float3((p3.x + p3.y) * p3.z, (p3.x + p3.z) * p3.y, (p3.y + p3.z) * p3.x)); + } + float N(float t) { return fract(sin(t * 12345.564) * 7658.76); } + float Saw(float b, float t) { return smoothstep(0.0, b, t) * smoothstep(1.0, b, t); } + + float2 DropLayer2(float2 uv, float t) { + float2 UV = uv; + uv.y += t * 0.75; + float2 a = float2(6.0, 1.0); + float2 grid = a * 2.0; + float2 id = floor(uv * grid); + float colShift = N(id.x); + uv.y += colShift; + id = floor(uv * grid); + float3 n = N13(id.x * 35.2 + id.y * 2376.1); + float2 st = fract(uv * grid) - float2(0.5, 0.0); + float x = n.x - 0.5; + float y = UV.y * 20.0; + float wiggle = sin(y + sin(y)); + x += wiggle * (0.5 - abs(x)) * (n.z - 0.5); + x *= 0.7; + float ti = fract(t + n.z); + y = (Saw(0.85, ti) - 0.5) * 0.9 + 0.5; + float2 p = float2(x, y); + float d = length((st - p) * a.yx); + float mainDrop = smoothstep(0.4, 0.0, d); + float r = sqrt(smoothstep(1.0, y, st.y)); + float cd = abs(st.x - x); + float trail = smoothstep(0.23 * r, 0.15 * r * r, cd); + float trailFront = smoothstep(-0.02, 0.02, st.y - y); + trail *= trailFront * r * r; + y = UV.y; + float trail2 = smoothstep(0.2 * r, 0.0, cd); + float droplets = max(0.0, (sin(y * (1.0 - y) * 120.0) - st.y)) * trail2 * trailFront * n.z; + y = fract(y * 10.0) + (st.y - 0.5); + float dd = length(st - float2(x, y)); + droplets = smoothstep(0.3, 0.0, dd); + float m = mainDrop + droplets * r * trailFront; + return float2(m, trail); + } + + float StaticDrops(float2 uv, float t) { + uv *= 40.0; + float2 id = floor(uv); + uv = fract(uv) - 0.5; + float3 n = N13(id.x * 107.45 + id.y * 3543.654); + float2 p = (n.xy - 0.5) * 0.7; + float d = length(uv - p); + float fade = Saw(0.025, fract(t + n.z)); + float c = smoothstep(0.3, 0.0, d) * fract(n.z * 10.0) * fade; + return c; + } + + float2 Drops(float2 uv, float t, float l0, float l1, float l2) { + float s = StaticDrops(uv, t) * l0; + float2 m1 = DropLayer2(uv, t) * l1; + float2 m2 = DropLayer2(uv * 1.85, t) * l2; + float c = s + m1.x + m2.x; + c = smoothstep(0.3, 1.0, c); + return float2(c, max(m1.y * l0, m2.y * l1)); + } + + half4 main(float2 xy) { + float2 res = mirageResolution; + float amount = clamp(rainAmount, 0.0, 1.0); + + float2 UV = xy / max(res, float2(1.0)); + float2 uv = (xy - 0.5 * res) / max(res.y, 1.0); + uv.y = -uv.y; + float scale = max(dropScale, 0.2); + uv *= scale; + + float T = mod(mirageTime, 120.0); + float t = T * 0.2; + + float2 maskUV = clamp(xy / max(res, float2(1.0)), 0.0, 1.0) * maskSize; + float2 mlo = float2(0.5); + float2 mhi = float2(maskSize - 0.5); + float2 base = floor(maskUV - 0.5) + 0.5; + float2 f = fract(maskUV - 0.5); + float w00 = clamp(float(wipeMask.eval(clamp(base, mlo, mhi)).r), 0.0, 1.0); + float w10 = clamp(float(wipeMask.eval(clamp(base + float2(1.0, 0.0), mlo, mhi)).r), 0.0, 1.0); + float w01 = clamp(float(wipeMask.eval(clamp(base + float2(0.0, 1.0), mlo, mhi)).r), 0.0, 1.0); + float w11 = clamp(float(wipeMask.eval(clamp(base + float2(1.0, 1.0), mlo, mhi)).r), 0.0, 1.0); + float wiped = mix(mix(w00, w10, f.x), mix(w01, w11, f.x), f.y); + + float maxBlur = mix(3.0, 6.0, amount); + float minBlur = 2.0; + float staticDrops = smoothstep(-0.5, 1.0, amount) * 2.0; + float layer1 = smoothstep(0.25, 0.75, amount); + float layer2 = smoothstep(0.0, 0.5, amount); + + float2 c = Drops(uv, t, staticDrops, layer1, layer2); + + float2 e = float2(0.001, 0.0); + float cx = Drops(uv + e, t, staticDrops, layer1, layer2).x; + float cy = Drops(uv + e.yx, t, staticDrops, layer1, layer2).x; + float2 n = float2(cx - c.x, cy - c.x); + n.y = -n.y; + + float focus = mix(maxBlur - c.y, minBlur, smoothstep(0.1, 0.2, c.x)); + focus = mix(focus, minBlur, clamp(wiped, 0.0, 1.0)); + float foggy = clamp((focus - minBlur) / max(maxBlur - minBlur, 1e-3), 0.0, 1.0); + foggy *= clamp(fogAmount, 0.0, 1.0); + + float2 nPx = n * res; + float nLen = length(nPx); + nPx *= min(nLen, 24.0) / max(nLen, 1e-4); + float2 tap = clamp((UV * res) + nPx, float2(0.5), res - 0.5); + half4 sharpBg = content.eval(tap); + + float st = max(blurRadius, 0.0) * foggy; + float st2 = st * 2.0; + + half4 fogged = sharpBg; + if (foggy > 0.01) { + half4 blur = sharpBg + + content.eval(clamp(tap + float2(-st2, -st2), float2(0.5), res - 0.5)) + + content.eval(clamp(tap + float2( st2, -st2), float2(0.5), res - 0.5)) + + content.eval(clamp(tap + float2(-st2, st2), float2(0.5), res - 0.5)) + + content.eval(clamp(tap + float2( st2, st2), float2(0.5), res - 0.5)); + blur *= half(1.0 / 5.0); + fogged = mix(sharpBg, blur, half(foggy)); + } + half3 haze = half3(0.86, 0.90, 0.94); + fogged.rgb = mix(fogged.rgb, haze, half(foggy) * half(clamp(hazeStrength, 0.0, 1.0))); + + half4 photo = content.eval(clamp(xy, float2(0.5), res - 0.5)); + + half4 col = mix(photo, fogged, half(clamp(introProgress, 0.0, 1.0))); + return col; + } + """.trimIndent() + return bindRainyWindowUniforms(RuntimeShaderBuilder(RuntimeEffect.makeForShader(source))) +} + +/** Binds both shaders under test at one look with a full-bleed lens covering the raster. */ +private fun bindRainyWindowUniforms(builder: RuntimeShaderBuilder): Shader { + builder.uniform("mirageResolution", RASTER.toFloat(), RASTER.toFloat()) + builder.uniform("mirageTime", 1.75f) + builder.uniform("maskSize", 256f) + builder.uniform("introProgress", 1f) + builder.uniform("rainAmount", 0.6f) + builder.uniform("blurRadius", 9f) + builder.uniform("fogAmount", 1f) + builder.uniform("hazeStrength", 0.55f) + builder.uniform("dropScale", 1f) + builder.child("content", contentShader()) + builder.child("wipeMask", wipeMaskShader()) + return builder.makeShader() +} + +private fun contentShader(): Shader = RuntimeEffect.makeForShader( + """ + half4 main(float2 xy) { + float2 uv = xy / float2($RASTER.0, $RASTER.0); + return half4(half(uv.x), half(uv.y), half(1.0 - uv.x), 1.0); + } + """.trimIndent(), +).let { RuntimeShaderBuilder(it).makeShader() } + +/** A partial finger-wipe mask: cleared (r=1) over a diagonal band, fogged (r=0) elsewhere. */ +private fun wipeMaskShader(): Shader = RuntimeEffect.makeForShader( + """ + half4 main(float2 xy) { + float v = step(64.0, xy.x + xy.y) * step(xy.x + xy.y, 320.0); + return half4(half(v), half(v), half(v), 1.0); + } + """.trimIndent(), +).let { RuntimeShaderBuilder(it).makeShader() } diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslSpecularTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslSpecularTest.kt new file mode 100644 index 00000000..dcaca854 --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageEdslSpecularTest.kt @@ -0,0 +1,236 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.MirageShaders +import com.skydoves.cloudy.internal.Dialect +import com.skydoves.cloudy.internal.MirageProgramCache +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.doubles.shouldBeGreaterThan +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.jetbrains.skia.RuntimeEffect +import org.jetbrains.skia.RuntimeShaderBuilder +import org.jetbrains.skia.Shader + +private const val RASTER = 64 + +/** + * Raster-parity gate for [MirageShaders.Specular] — the heaviest kernel ported through the eDSL: a + * mutable local ([MutableLocal], `pixel`) reassigned once from a `content.eval` fallback and again + * inside a non-exiting `if` block ([IfBlock]), a `&&`-gated highlight, multiple `content.eval` taps, + * and `pow`/`sqrt`/`select` (the superellipse sign terms). Proves the eDSL-traced source rasterizes + * byte-for-byte identically to the pre-eDSL hand-written kernel text with the highlight gate open + * (`specStrength > 0`), so the `IfBlock` body is actually exercised, not just its guard. + */ +internal class MirageEdslSpecularTest : + FunSpec({ + + test("MirageShaders.Specular compiles and rasterizes through the real skiko RuntimeEffect") { + meanAbsDiff( + rasterize(buildEdslSpecularShader(), RASTER), + rasterize(handRolledSpecularShader(), RASTER), + ) shouldBe 0.0 + } + + test("the eDSL-traced highlight actually brightens pixels, not a no-op") { + val withHighlight = rasterize(buildEdslSpecularShader(), RASTER) + val withoutHighlight = rasterize(buildEdslSpecularShader(specStrength = 0f), RASTER) + meanAbsDiff(withHighlight, withoutHighlight).shouldBeGreaterThan(0.5) + } + }) + +private fun buildEdslSpecularShader(specStrength: Float = 0.7f): Shader { + val cached = MirageProgramCache.obtain(MirageShaders.Specular, Dialect.Sksl).shouldNotBeNull() + return bindSpecularUniforms( + RuntimeShaderBuilder(RuntimeEffect.makeForShader(cached.compiled.source)), + specStrength, + ) +} + +/** + * The pre-eDSL kernel text (what `SPECULAR_KERNEL_SKSL` used to read before this port), kept here only + * as the golden reference for the raster-parity test above — not reachable from production code. + */ +private fun handRolledSpecularShader(): Shader { + val source = """ + const float SMOOTH_EDGE_PX = 1.5; + const float SPEC_SE_POW = 4.0; + + float boxRoundedSDF(float2 p, float2 halfDim, float r) { + float2 d = abs(p) - halfDim + float2(r); + float exterior = length(max(d, 0.0)); + float interior = min(max(d.x, d.y), 0.0); + return exterior + interior - r; + } + + float2 lensNormalDirection(float2 p, float2 halfDim, float r) { + float2 d = abs(p) - halfDim + float2(r); + float2 s = float2(p.x >= 0.0 ? 1.0 : -1.0, p.y >= 0.0 ? 1.0 : -1.0); + if (max(d.x, d.y) > 0.0) { + return s * normalize(max(d, 0.0)); + } + return d.x > d.y ? float2(s.x, 0.0) : float2(0.0, s.y); + } + + float toBrightness(half3 c) { + return dot(c, half3(0.2126, 0.7152, 0.0722)); + } + + half3 processColor(half3 src, float vibrancy, float intensity, float4 overlay) { + float mono = toBrightness(src); + half3 vibrant = half3(clamp(mix(half3(mono), src, vibrancy), 0.0, 1.0)); + half3 adjusted = half3(clamp((vibrant - 0.5) * intensity + 0.5, 0.0, 1.0)); + return mix(adjusted, half3(overlay.rgb), overlay.a); + } + + uniform float2 lensCenter; + uniform float2 lensSize; + uniform float cornerRadius; + uniform float2 iLight; + uniform float specStrength; + uniform float specPower; + uniform float specRimMix; + uniform float specWidthPx; + uniform float specLightZ; + uniform float specDomeFrac; + uniform float specBodyPower; + uniform float specBodyGain; + uniform float specFocalK; + uniform float specPoolFrac; + uniform float specPoolGain; + uniform shader content; + + half4 main(float2 xy) { + float2 halfDim = lensSize * 0.5; + float r = min(cornerRadius, min(halfDim.x, halfDim.y)); + + float2 p = xy - lensCenter; + float sdf = boxRoundedSDF(p, halfDim, r); + + if (sdf > SMOOTH_EDGE_PX) { + return content.eval(xy); + } + + float2 normal = lensNormalDirection(p, halfDim, r); + + float2 sampleXY = xy; + { + float minDim = min(halfDim.x, halfDim.y); + float depth = clamp(-sdf / (minDim * 0.25), 0.0, 1.0); + float curvature = 1.0 - depth; + float bend = 1.0 - sqrt(1.0 - curvature * curvature); + sampleXY = xy - bend * 0.25 * minDim * normal; + } + + half4 pixel = content.eval(sampleXY); + + if (pixel.a <= 0.0) { + pixel = content.eval(xy); + } + + pixel.rgb = processColor(pixel.rgb, 1.0, 1.0, float4(0.0, 0.0, 0.0, 0.0)); + + float edge = 0.2; + + if (edge > 0.0 && specStrength > 0.0) { + float2 lightVec = normalize(iLight); + + float minHalf = min(halfDim.x, halfDim.y); + float2 q = abs(p) / max(halfDim, float2(1.0)); + float2 s2 = float2(p.x >= 0.0 ? 1.0 : -1.0, p.y >= 0.0 ? 1.0 : -1.0); + float seF = pow(pow(q.x, SPEC_SE_POW) + pow(q.y, SPEC_SE_POW), 1.0 / SPEC_SE_POW); + float2 specDir2 = normalize( + s2 * float2(SPEC_SE_POW * pow(q.x, SPEC_SE_POW - 1.0) / max(halfDim.x, 1.0), + SPEC_SE_POW * pow(q.y, SPEC_SE_POW - 1.0) / max(halfDim.y, 1.0)) + + float2(1.0e-4, 1.0e-4)); + + float t = clamp(seF / max(specDomeFrac, 1.0e-2), 0.0, 1.0); + float n_cos = 1.0 - t; + float n_sin = sqrt(max(1.0 - n_cos * n_cos, 0.0)); + float3 N = normalize(float3(specDir2 * n_cos, n_sin + 1.0e-3)); + + float3 L = normalize(float3(lightVec, specLightZ)); + float3 V = float3(0.0, 0.0, 1.0); + + float2 focal = lightVec * (minHalf * specFocalK); + float poolR = max(minHalf * specPoolFrac, 1.0); + float poolD = length(p - focal); + float pool = 1.0 - smoothstep(0.0, poolR, poolD); + float inside = 1.0 - smoothstep(-6.0, 0.0, sdf); + float focalPool = pool * pool * specStrength * specPoolGain * inside; + + float ndl = max(dot(N, L), 0.0); + float bodySheen = pow(ndl, specBodyPower) * specStrength * specBodyGain; + + float3 H = normalize(L + V); + float rimBand = smoothstep(-max(specWidthPx, 1.0), 0.0, sdf); + float glint = pow(max(dot(N, H), 0.0), specPower) * specStrength; + float rim = glint * rimBand; + + float3 Lb = normalize(float3(-lightVec, specLightZ)); + float back = pow(max(dot(N, Lb), 0.0), specPower) * specStrength * rimBand * 0.25; + + float2 hp = fract((p / minHalf) * 0.5 + 0.5); + float dn = fract(sin(dot(hp, float2(12.9898, 78.233))) * 43758.5453) - 0.5; + + float body = focalPool + bodySheen + dn * (1.0 / 255.0) * specStrength; + float rimMix = clamp(specRimMix, 0.0, 1.0); + float highlight = body * (1.0 - rimMix) + (rim + back) * rimMix; + + pixel.rgb += half3((1.0 - pixel.rgb) * clamp(highlight, 0.0, 1.0)); + } + + float alpha = 1.0 - smoothstep(-SMOOTH_EDGE_PX * 0.5, SMOOTH_EDGE_PX * 0.5, sdf); + half4 bg = content.eval(xy); + return mix(bg, pixel, alpha); + } + """.trimIndent() + return bindSpecularUniforms(RuntimeShaderBuilder(RuntimeEffect.makeForShader(source)), 0.7f) +} + +/** The one non-default Specular look both shaders under test are compared at (lens covers the raster). */ +private fun bindSpecularUniforms(builder: RuntimeShaderBuilder, specStrength: Float): Shader { + builder.uniform("lensCenter", RASTER / 2f, RASTER / 2f) + builder.uniform("lensSize", RASTER.toFloat(), RASTER.toFloat()) + builder.uniform("cornerRadius", 8f) + builder.uniform("iLight", -1f, -1f) + builder.uniform("specStrength", specStrength) + builder.uniform("specPower", 10f) + builder.uniform("specRimMix", 0.4f) + builder.uniform("specWidthPx", 12f) + builder.uniform("specLightZ", 0.55f) + builder.uniform("specDomeFrac", 1.15f) + builder.uniform("specBodyPower", 2.5f) + builder.uniform("specBodyGain", 0.6f) + builder.uniform("specFocalK", 0.55f) + builder.uniform("specPoolFrac", 0.7f) + builder.uniform("specPoolGain", 1.3f) + builder.child("content", contentShader()) + return builder.makeShader() +} + +private fun contentShader(): Shader = RuntimeEffect.makeForShader( + """ + half4 main(float2 xy) { + float2 uv = xy / float2($RASTER.0, $RASTER.0); + return half4(half(uv.x), half(uv.y), half(1.0 - uv.x), 1.0); + } + """.trimIndent(), +).let { RuntimeShaderBuilder(it).makeShader() } diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageLoopTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageLoopTest.kt new file mode 100644 index 00000000..4cf874bf --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageLoopTest.kt @@ -0,0 +1,126 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.MirageShader +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.doubles.shouldBeLessThanOrEqual +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.string.shouldContain +import org.jetbrains.skia.RuntimeEffect +import org.jetbrains.skia.RuntimeShaderBuilder +import org.jetbrains.skia.Shader + +private const val RASTER = 32 + +/** + * F6 loops. [unroll] replays the body n times at trace time (no GLSL `for`), so the emitted source has n + * inlined copies and no loop header. [loop] emits a real ES2-safe `for` with a constant bound and an + * inner `if (index >= count) break` (KorGE's FOR_0_UNTIL_FIXED_BREAK pattern). Both are checked on the + * emitted text, and the `unroll` box-blur kernel is also rasterized against a hand-written equivalent. + */ +internal class MirageLoopTest : + FunSpec({ + + test("unroll inlines the body n times with no for header") { + val kernel = MirageShader.composite("unrollBlur", ::EmptyParams) { xy -> + var acc by local(half4(0f)) + unroll(3) { i -> + acc = acc + sampleContent(xy + float2(i.toFloat(), 0f)) + } + acc * half(1f / 3f) + }.agsl + + kernel.shouldNotContainFor() + // Three content taps, one per unrolled iteration. + Regex("content\\.eval").findAll(kernel).count() shouldBeExactly 3 + } + + test("loop emits an ES2-safe for with a constant bound and an inner break") { + val kernel = MirageShader.composite("dynLoop", ::EmptyParams) { xy -> + var acc by local(half4(0f)) + loop(count = float1(4f), maxIterations = 8) { index -> + acc = acc + sampleContent(xy + float2(index, float1(0f))) + } + acc + }.agsl + + kernel shouldContain "for (int _i = 0; _i < 8; _i++)" + kernel shouldContain "= float(_i);" + kernel shouldContain ">= 4.0) break;" + } + + test("the unroll box-blur rasterizes like a hand-written 3-tap accumulation") { + meanAbsDiff( + rasterize(edslUnrollShader(), RASTER), + rasterize(handRolledUnrollShader(), RASTER), + ).shouldBeLessThanOrEqual(0.5) + } + }) + +private fun edslUnrollShader(): Shader { + val kernel = MirageShader.composite("unrollBlurRaster", ::EmptyParams) { xy -> + var acc by local(half4(0f)) + unroll(3) { i -> + acc = acc + sampleContent(xy + float2(i.toFloat() * 2f, 0f)) + } + acc * half(1f / 3f) + }.agsl + return bind(kernel) +} + +private fun handRolledUnrollShader(): Shader = bind( + """ + half4 main(float2 xy) { + half4 acc = half4(0.0); + acc = acc + content.eval(xy + float2(0.0, 0.0)); + acc = acc + content.eval(xy + float2(2.0, 0.0)); + acc = acc + content.eval(xy + float2(4.0, 0.0)); + return acc * half(1.0 / 3.0); + } + """.trimIndent(), +) + +/** + * Prepends the `uniform shader content;` declaration the compiler's preamble would supply (the emitted + * `main` body assumes it in scope), then binds a content child — enough to rasterize the body standalone. + */ +private fun bind(mainBody: String): Shader { + val source = "uniform shader content;\n$mainBody" + val builder = RuntimeShaderBuilder(RuntimeEffect.makeForShader(source)) + builder.child("content", contentShader()) + return builder.makeShader() +} + +private fun contentShader(): Shader = RuntimeEffect.makeForShader( + """ + half4 main(float2 xy) { + float2 uv = xy / float2($RASTER.0, $RASTER.0); + return half4(half(uv.x), half(uv.y), half(1.0 - uv.x), 1.0); + } + """.trimIndent(), +).let { RuntimeShaderBuilder(it).makeShader() } + +private infix fun Int.shouldBeExactly(expected: Int) { + if (this != expected) throw AssertionError("expected $expected but was $this") +} + +private fun String.shouldNotContainFor() { + if (contains("for (")) throw AssertionError("unroll must not emit a for loop:\n$this") +} diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageReservedNamesTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageReservedNamesTest.kt new file mode 100644 index 00000000..a04916b5 --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageReservedNamesTest.kt @@ -0,0 +1,52 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.cloudy.edsl + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import java.io.File + +/** + * Divergence gate for [MirageReservedNames]: every function name `ShaderValues` emits via `Call("name", + * ...)` must be reserved, or a future user `shaderFunction` could reuse it and silently miscompile. The + * two lists live apart (one emits, one reserves), so this test reads the emitter source and fails if any + * emitted builtin is missing from `RESERVED`. + */ +internal class MirageReservedNamesTest : + FunSpec({ + + test("every emitted builtin is reserved") { + // Walk up to the repo root (Gradle sets user.dir to the module dir for JVM tests), then read the + // emitter source off disk — the same on-disk lookup RainyWindowRasterTest uses. + var dir: File? = File(System.getProperty("user.dir")).absoluteFile + while (dir != null && !File(dir, "settings.gradle.kts").exists()) { + dir = dir.parentFile + } + requireNotNull(dir) { "could not locate the repo root (settings.gradle.kts) from user.dir" } + val source = File( + dir, + "cloudy/src/commonMain/kotlin/com/skydoves/cloudy/edsl/ShaderValues.kt", + ) + require(source.exists()) { "ShaderValues.kt not found at ${source.absolutePath}" } + + val emitted = Regex("""Call\("([A-Za-z_][A-Za-z0-9_]*)"""") + .findAll(source.readText()) + .map { it.groupValues[1] } + .toSet() + + (emitted - MirageReservedNames.RESERVED).shouldBeEmpty() + } + }) diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageShaderFunctionTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageShaderFunctionTest.kt new file mode 100644 index 00000000..4424f6cb --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageShaderFunctionTest.kt @@ -0,0 +1,89 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.MirageShader +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +/** + * F5 `shaderFunction`: a user helper declared with `val by shaderFunction(...)` is emitted as a + * real GLSL function named after the property and called from the kernel. Reserved builtin names are + * rejected, and a declared/traced return-type mismatch is a diagnostic. + */ +internal class MirageShaderFunctionTest : + FunSpec({ + + test("a user shaderFunction is emitted as a named GLSL function the kernel calls") { + val kernel = MirageShader.generate("rings", ::EmptyParams) { xy -> + val sdCircle by shaderFunction(Float2Type, Float1Type) { p -> length(p) - 0.5f } + val d = sdCircle(xy) + half4(half3(float3(d, d, d)), half(1f)) + }.agsl + + kernel shouldContain "float sdCircle(float2 p0)" + kernel shouldContain "sdCircle(xy)" + } + + test("nested shaderFunction calls emit dependency-first (callee declared before caller)") { + // 3-level nesting mirroring RainyWindow's drops -> dropLayer2 -> n13: + // a 5-param helper calls a 2-param helper, which calls a 1-param helper. + val kernel = MirageShader.generate("nested", ::EmptyParams) { xy -> + val leaf by shaderFunction(Float1Type, Float1Type) { p -> p * 2f } + val mid by shaderFunction(Float2Type, Float1Type, Float1Type) { uv, t -> + leaf(uv.x) + t + } + val top by shaderFunction( + Float2Type, + Float1Type, + Float1Type, + Float1Type, + Float1Type, + Float1Type, + ) { uv, t, l0, l1, l2 -> + mid(uv, t) + l0 + l1 + l2 + } + val d = top(xy, float1(1f), float1(2f), float1(3f), float1(4f)) + half4(half3(float3(d, d, d)), half(1f)) + }.agsl + + kernel shouldContain "float leaf(float p0)" + kernel shouldContain "float mid(float2 p0, float p1)" + kernel shouldContain "float top(float2 p0, float p1, float p2, float p3, float p4)" + + // Dependency-first: each callee's declaration precedes its caller's declaration. + val leafAt = kernel.indexOf("float leaf(") + val midAt = kernel.indexOf("float mid(") + val topAt = kernel.indexOf("float top(") + (leafAt in 0 until midAt) shouldBe true + (midAt in 0 until topAt) shouldBe true + } + + test("a shaderFunction named after a builtin fails with RESERVED_IDENTIFIER") { + val ex = shouldThrow { + MirageShader.generate("reservedFn", ::EmptyParams) { xy -> + val mix by shaderFunction(Float2Type, Float1Type) { p -> length(p) } + half4(half3(float3(mix(xy), mix(xy), mix(xy))), half(1f)) + } + } + ex.code shouldBe MirageDiagnosticCode.RESERVED_IDENTIFIER + } + }) diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageWriteSwizzleTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageWriteSwizzleTest.kt new file mode 100644 index 00000000..958479dc --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/MirageWriteSwizzleTest.kt @@ -0,0 +1,59 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import com.skydoves.cloudy.ExperimentalMirage +import com.skydoves.cloudy.MirageParams +import com.skydoves.cloudy.MirageShader +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.string.shouldContain + +/** A uniform-free probe params for synthetic F4/F6/F7 test kernels. */ +@ExperimentalMirage +internal class EmptyParams : MirageParams() + +/** + * F4 write-swizzle: assigning `pixel.rgb` / `pixel.a` on a `var pixel by local(...)` emits an in-place + * channel write (`_v0.rgb = ...;`), not a rebuilt `half4(...)`. Verified on the emitted source text. + */ +internal class MirageWriteSwizzleTest : + FunSpec({ + + test("pixel.rgb and pixel.a writes emit in-place channel assignments") { + val kernel = MirageShader.generate("writeSwizzle", ::EmptyParams) { xy -> + var pixel by local(half4(half3(0.2f, 0.4f, 0.6f), half(1f))) + pixel.rgb = mix(pixel.rgb, half3(1f, 1f, 1f), half(0.5f)) + pixel.a = half(0.25f) + pixel + }.agsl + + kernel shouldContain ".rgb = " + kernel shouldContain ".a = " + } + + test("pixel.rgb += lowers through plus to a single .rgb write (no plusAssign)") { + val kernel = MirageShader.generate("writeSwizzlePlus", ::EmptyParams) { xy -> + var pixel by local(half4(half3(0.1f, 0.1f, 0.1f), half(1f))) + pixel.rgb += half3(0.2f, 0.2f, 0.2f) + pixel + }.agsl + + // get -> plus -> set: exactly one `.rgb =` write whose RHS adds to the read `.rgb`. + kernel shouldContain ".rgb = (" + } + }) diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/RasterTestUtils.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/RasterTestUtils.kt new file mode 100644 index 00000000..afa2694e --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/RasterTestUtils.kt @@ -0,0 +1,50 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.cloudy.edsl + +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Shader +import org.jetbrains.skia.Surface +import kotlin.math.abs + +/** Draws [shader] over an opaque [size]x[size] raster and returns its RGBA_8888 bytes. */ +internal fun rasterize(shader: Shader, size: Int): ByteArray { + val info = ImageInfo(size, size, ColorType.RGBA_8888, ColorAlphaType.PREMUL) + return Surface.makeRaster(info).use { surface -> + Paint().use { paint -> + paint.shader = shader + surface.canvas.drawPaint(paint) + } + Bitmap().use { bitmap -> + bitmap.allocPixels(info) + surface.readPixels(bitmap, 0, 0) + bitmap.readPixels() ?: error("readPixels returned null") + } + } +} + +/** Mean absolute per-byte difference between two equally sized RGBA buffers (0..255 scale). */ +internal fun meanAbsDiff(a: ByteArray, b: ByteArray): Double { + require(a.size == b.size) { "buffers differ in size: ${a.size} vs ${b.size}" } + var sum = 0L + for (i in a.indices) sum += abs((a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF)) + return sum.toDouble() / a.size +} diff --git a/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/ShaderLiteralsTest.kt b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/ShaderLiteralsTest.kt new file mode 100644 index 00000000..cf24fbf1 --- /dev/null +++ b/cloudy/src/desktopTest/kotlin/com/skydoves/cloudy/edsl/ShaderLiteralsTest.kt @@ -0,0 +1,46 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalMirage::class) + +package com.skydoves.cloudy.edsl + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import com.skydoves.cloudy.ExperimentalMirage +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf + +/** + * Host-type literals bake a color/geometry constant into the source as a plain constructor call — no new + * IR node, and (per the color-space contract) the channels are emitted raw, with no sRGB conversion. + */ +internal class ShaderLiteralsTest : + FunSpec({ + + test("color(argb) splits 0xAARRGGBB into raw 0..1 channels as a half4 call") { + val call = color(0xFF3366CCL).e.shouldBeInstanceOf() + call.functionName shouldBe "half4" + // 0x33=51, 0x66=102, 0xCC=204, 0xFF=255 over 255. + call.args.map { (it as Literal).value } shouldBe + listOf(51f / 255f, 102f / 255f, 204f / 255f, 1f) + } + + test("offset/size emit a float2 constructor with the raw components") { + (offset(Offset(3f, 4f)).e as Call).args.map { (it as Literal).value } shouldBe listOf(3f, 4f) + (size(Size(10f, 20f)).e as Call).args.map { (it as Literal).value } shouldBe listOf(10f, 20f) + } + })