feat(ui): implement theme reveal transition using AGSL

This commit is contained in:
DarkKnight2122
2026-03-18 19:03:37 +05:30
parent 84a9d01d6a
commit fc66453167
8 changed files with 537 additions and 15 deletions

View File

@@ -46,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.ThemePreferences
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.CircularRevealOverlay
import me.eternal.purrfectsnap.ui.util.ThankYouDialog
import android.content.IntentFilter
@@ -212,6 +213,16 @@ class MainActivity : ComponentActivity() {
}
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
navigation.NavContent(contentPadding, startDestination)
// Theme Reveal Overlay
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
CircularRevealOverlay(
context = managerContext,
request = revealRequest,
onComplete = { navigation.themeRevealState.clearReveal() }
)
}
Box(
modifier = Modifier
.align(Alignment.TopCenter)

View File

@@ -104,6 +104,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navigation
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.ThemeRevealState
import kotlin.math.round
import kotlin.math.PI
import kotlin.math.sin
@@ -122,6 +123,7 @@ class Navigation(
private val translation by lazy { context.translation.getCategory("manager.navigation") }
var openBottomBarCustomization by mutableStateOf(false)
var globalScrollOffset by mutableIntStateOf(0)
val themeRevealState = ThemeRevealState()
@Composable
fun TopBar() {

View File

@@ -47,12 +47,17 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.ui.util.openFile
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import java.io.File
import java.net.URLEncoder
@@ -62,6 +67,8 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val hapticFeedback = LocalHapticFeedback.current
val view = LocalView.current
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var controlsHeight by remember { mutableStateOf(100.dp) }
var showResetSetupDialog by remember { mutableStateOf(false) }
@@ -134,17 +141,49 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp)
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
var localThemeId by remember { mutableStateOf(currentThemeId) }
Switch(
checked = currentThemeId == "APHELION",
checked = localThemeId == "APHELION",
onCheckedChange = { isAphelion ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) }
val newId = if (isAphelion) "APHELION" else "LEGACY"
context.config.root.global.uiSettings.managerTheme.set(newId)
context.config.writeConfig()
localThemeId = newId // Update UI instantly
AphelionHaptics.themeRevealTick(context, hapticFeedback)
// 1. Capture bitmap BEFORE theme change
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
// 2. Request Reveal
routes.navigation?.themeRevealState?.requestReveal(
newThemeId = newId,
originCenter = switchCenter,
bitmap = bitmap
)
// 3. Apply theme and persist
scope.launch {
kotlinx.coroutines.delay(50)
context.config.root.global.uiSettings.managerTheme.set(newId)
// Write to disk immediately on IO thread and finish
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
context.config.writeConfig()
}
writeJob.join() // Ensure it finishes its work before scope potentially closes
}
},
modifier = Modifier.padding(end = 26.dp),
modifier = Modifier
.padding(end = 26.dp)
.onGloballyPositioned { coords ->
val rootPos = coords.positionInRoot()
switchCenter = androidx.compose.ui.geometry.Offset(
x = rootPos.x + coords.size.width / 2f,
y = rootPos.y + coords.size.height / 2f
)
},
colors = purrfectSwitchColors()
)
}

View File

@@ -45,9 +45,18 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.core.view.drawToBitmap
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -610,6 +619,8 @@ object LegacyTheme : ThemeContract {
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val hapticFeedback = LocalHapticFeedback.current
val view = LocalView.current
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
val positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"]
val importLabel = context.translation["button.import"]
@@ -697,19 +708,48 @@ object LegacyTheme : ThemeContract {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp)
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
Switch(
checked = currentThemeId == "APHELION",
var localThemeId by remember { mutableStateOf(currentThemeId) }
Switch( checked = localThemeId == "APHELION",
onCheckedChange = { isAphelion ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
val newId = if (isAphelion) "APHELION" else "LEGACY"
context.config.root.global.uiSettings.managerTheme.set(newId)
context.config.writeConfig()
localThemeId = newId // Update UI instantly
AphelionHaptics.themeRevealTick(context, hapticFeedback)
// 1. Capture bitmap BEFORE theme change
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
// 2. Request Reveal
routes.navigation?.themeRevealState?.requestReveal(
newThemeId = newId,
originCenter = switchCenter,
bitmap = bitmap
)
// 3. Apply theme and persist
scope.launch {
kotlinx.coroutines.delay(50)
context.config.root.global.uiSettings.managerTheme.set(newId)
// Write to disk immediately on IO thread and finish
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
context.config.writeConfig()
}
writeJob.join() // Wait for write to finish
}
},
modifier = Modifier.padding(end = 26.dp),
modifier = Modifier
.padding(end = 26.dp)
.onGloballyPositioned { coords ->
val rootPos = coords.positionInRoot()
switchCenter = androidx.compose.ui.geometry.Offset(
x = rootPos.x + coords.size.width / 2f,
y = rootPos.y + coords.size.height / 2f
)
},
colors = purrfectSwitchColors()
)
}

View File

@@ -0,0 +1,60 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import me.eternal.purrfectsnap.RemoteSideContext
/**
* Specialized haptic engine for the Aphelion "Liquid Glass" experience.
* Provides more nuanced feedback than standard Compose haptics.
*/
object AphelionHaptics {
/**
* Triggers a subtle, sharp "tick" intended for the start of a theme reveal.
*/
fun themeRevealTick(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
runCatching {
if (!shouldPerformHaptics(remoteSideContext)) return
val androidContext = remoteSideContext.androidContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = androidContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
val vibrator = vibratorManager?.defaultVibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else {
// Fallback for older APIs
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}.onFailure { it.printStackTrace() }
}
/**
* Triggers a "soft" impact feel, good for glass interactions.
*/
fun softImpact(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
runCatching {
if (!shouldPerformHaptics(remoteSideContext)) return
val androidContext = remoteSideContext.androidContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}.onFailure { it.printStackTrace() }
}
private fun shouldPerformHaptics(remoteSideContext: RemoteSideContext): Boolean {
return remoteSideContext.config.root.global.uiSettings.hapticFeedback.get()
}
}

View File

@@ -0,0 +1,223 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import android.graphics.BitmapShader
import android.graphics.Shader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlin.math.sqrt
import androidx.compose.ui.platform.LocalHapticFeedback
import me.eternal.purrfectsnap.RemoteSideContext
private const val REVEAL_DURATION_MS = 3200
private const val WAVE_BAND_WIDTH_PX = 300f
private const val BLUR_ZONE_PX = 120f
private const val BLUR_RADIUS = 30f
private const val FADE_ZONE_PX = 80f
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
@Composable
fun CircularRevealOverlay(
context: RemoteSideContext,
request: ThemeRevealRequest,
onComplete: () -> Unit
) {
// Safety check: if bitmap was recycled or is null, skip.
val bitmap = request.oldThemeBitmap ?: run {
LaunchedEffect(request.id) { onComplete() }
return
}
if (bitmap.isRecycled) {
LaunchedEffect(request.id) { onComplete() }
return
}
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val hapticFeedback = LocalHapticFeedback.current
// Ensure the reveal is cleared even if navigation happens mid-animation
DisposableEffect(request.id) {
onDispose { onComplete() }
}
val maxRadius = remember(configuration) {
with(density) {
val w = configuration.screenWidthDp.dp.toPx()
val h = configuration.screenHeightDp.dp.toPx()
sqrt(w * w + h * h)
}
}
val animatedRadius = remember(request.id) { Animatable(0f) }
LaunchedEffect(request.id) {
AphelionHaptics.themeRevealTick(context, hapticFeedback)
animatedRadius.animateTo(
targetValue = maxRadius + WAVE_BAND_WIDTH_PX,
animationSpec = tween(durationMillis = REVEAL_DURATION_MS, easing = AphelionEasing)
)
onComplete()
}
val progress = (animatedRadius.value / (maxRadius + WAVE_BAND_WIDTH_PX)).coerceIn(0f, 1f)
val infiniteTransition = rememberInfiniteTransition(label = "wave_time")
val timeValue by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 10f,
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 5_000, easing = LinearEasing)),
label = "wave_time_value"
)
val runtimeShader = remember(bitmap) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
}
} else null
}
val shaderPaint = remember(runtimeShader, bitmap) {
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = runtimeShader ?: BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = animatedRadius.value
val center = request.originCenter
drawIntoCanvas { canvas ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && runtimeShader != null) {
runtimeShader.setFloatUniform("revealRadius", radius)
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
runtimeShader.setFloatUniform("time", timeValue)
runtimeShader.setFloatUniform("uProgress", progress)
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
drawWithBlurReveal(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
} else {
drawWithClipFade(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.S)
private fun drawWithBlurReveal(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerRingRadius = (radius - BLUR_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerRingRadius, android.graphics.Path.Direction.CW)
}
val renderNode = android.graphics.RenderNode("blurRing").apply {
setPosition(0, 0, canvasWidth.toInt(), canvasHeight.toInt())
setRenderEffect(android.graphics.RenderEffect.createBlurEffect(BLUR_RADIUS, BLUR_RADIUS, Shader.TileMode.CLAMP))
}
val nodeCanvas = renderNode.beginRecording()
nodeCanvas.save()
nodeCanvas.clipPath(ringPath)
nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
nodeCanvas.restore()
renderNode.endRecording()
canvas.drawRenderNode(renderNode)
val shimmerPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(50, 255, 255, 255), android.graphics.Color.TRANSPARENT),
floatArrayOf((innerRingRadius / radius).coerceIn(0f, 1f), ((radius - BLUR_ZONE_PX * 0.25f) / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, shimmerPaint)
canvas.restore()
}
private fun drawWithClipFade(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerFadeRadius = (radius - FADE_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerFadeRadius, android.graphics.Path.Direction.CW)
}
val fadePaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(80, 255, 255, 255)),
floatArrayOf((innerFadeRadius / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, fadePaint)
canvas.restore()
}

View File

@@ -0,0 +1,61 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
/**
* Carries all data needed to execute one theme reveal transition.
*/
data class ThemeRevealRequest(
val newThemeId: String,
val originCenter: Offset,
val oldThemeBitmap: android.graphics.Bitmap?,
val id: Long = System.currentTimeMillis()
)
/**
* Observable state that lives on the [Navigation] instance.
* Optimized for stability during rapid toggle events.
*/
class ThemeRevealState {
var pendingReveal by mutableStateOf<ThemeRevealRequest?>(null)
private set
/**
* Requests a new theme reveal animation.
* Always starts a new reveal immediately, even if one is already in progress.
*/
fun requestReveal(
newThemeId: String,
originCenter: Offset,
bitmap: android.graphics.Bitmap?
) {
// Clean up the old one first to prevent memory leaks and "dead periods"
val oldBitmap = pendingReveal?.oldThemeBitmap
if (oldBitmap?.isRecycled == false) {
oldBitmap.recycle()
}
// Immediately update with the new request ID to force a fresh animation
pendingReveal = ThemeRevealRequest(
newThemeId = newThemeId,
originCenter = originCenter,
oldThemeBitmap = bitmap,
id = System.currentTimeMillis() // Unique ID ensures fresh start
)
}
/** Called by the overlay composable once the animation has fully completed. */
fun clearReveal() {
val oldBitmap = pendingReveal?.oldThemeBitmap
pendingReveal = null
// Manual memory management for the heavy screenshot bitmap
if (oldBitmap?.isRecycled == false) {
oldBitmap.recycle()
}
}
}

View File

@@ -0,0 +1,86 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
/**
* Finalized "Perfect Optic" AGSL Shader.
* Features balanced thickness, high-impact refraction, and explosive kinetic energy.
*/
object WaveEdgeShader {
const val AGSL = """
uniform shader content;
uniform float revealRadius;
uniform float2 revealCenter;
uniform float bandWidth;
uniform float time;
uniform float uProgress;
float hash(float2 p) {
return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123);
}
float valueNoise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x),
u.y
);
}
half4 main(float2 pos) {
float d = distance(pos, revealCenter);
float energy = (1.0 - uProgress);
// Slower, more majestic large noise
float noiseLarge = valueNoise(pos * 0.003 + time * 0.08) * 70.0;
float totalNoise = noiseLarge * energy;
float dist = d - (revealRadius + totalNoise);
if (dist > 0.0) {
return content.eval(pos);
}
// Reverting to balanced thickness (Starts at 50% width, grows to 100%)
float dynamicBand = bandWidth * (0.5 + 0.5 * uProgress);
float effectStart = revealRadius - dynamicBand;
if (dist < -dynamicBand) {
return half4(0.0);
}
float bandProgress = clamp((dist + dynamicBand) / dynamicBand, 0.0, 1.0);
// Asymmetric crest: Sharp start, long slow tail
float waveShape = pow(bandProgress, 2.0);
float2 dir = normalize(pos - revealCenter + 0.001);
// Refraction: Impactful but clear
float refractionAmt = waveShape * (60.0 * energy + 25.0) + (totalNoise * 0.15);
float2 refractedPos = pos + dir * refractionAmt;
// Chromatic Aberration: Deep prism split
float aberration = waveShape * (35.0 * energy + 10.0);
half r = content.eval(refractedPos + dir * aberration).r;
half g = content.eval(refractedPos).g;
half b = content.eval(refractedPos - dir * aberration).b;
// Sharp highlight at the front edge
float highlight = pow(waveShape, 1.1) * (0.5 * energy + 0.15);
// Leading edge softening (minor)
float leadingEdgeFade = smoothstep(revealRadius, revealRadius - 10.0, d - totalNoise);
float alpha = smoothstep(0.0, 0.25, bandProgress) * leadingEdgeFade;
return half4(
r + half(highlight),
g + half(highlight),
b + half(highlight),
half(alpha)
);
}
"""
}