Created
June 7, 2026 08:56
-
-
Save moshenskyi/5f90cefb50ebce787bbba014b0e5c2aa to your computer and use it in GitHub Desktop.
AGSL Lens Effect Shader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Copyright 2026 Nazarii Moshenskyi | |
| * | |
| * 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. | |
| */ | |
| import android.graphics.RenderEffect | |
| import android.graphics.RuntimeShader | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.gestures.detectDragGestures | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Rect | |
| import androidx.compose.ui.graphics.CompositingStrategy | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.asComposeRenderEffect | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.layout.boundsInParent | |
| import androidx.compose.ui.layout.onGloballyPositioned | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import org.intellij.lang.annotations.Language | |
| @Language("AGSL") | |
| private const val MAGNIFIER_SHADER = | |
| """ | |
| uniform shader composable; | |
| uniform float2 center; | |
| uniform float radius; | |
| const float LENS_BORDER_WIDTH = 36.0; | |
| const float ANTI_ALIASING_DIST = 2.0; | |
| half4 mixGlass(float2 fragCoord, half4 original) { | |
| float2 direction = fragCoord - center; | |
| float dist = length(direction); | |
| if (dist > radius) { | |
| return original; | |
| } | |
| float normalized = dist / radius; | |
| float lens = 1.0 - normalized * normalized; | |
| float2 uv = center + direction / (1.0 + 0.7 * lens); | |
| half4 color = composable.eval(uv); | |
| float edgeFade = 1.0 - smoothstep(radius - ANTI_ALIASING_DIST, radius, dist); | |
| return mix(original, color, half(edgeFade)); | |
| } | |
| float handleMask(float2 fragCoord, float2 start, float2 end, float radius) { | |
| float2 pixelOffsetFromStart = fragCoord - start; | |
| float2 handleVector = end - start; | |
| float projectionOnHandle = dot(pixelOffsetFromStart, handleVector); | |
| float handleLengthSquared = dot(handleVector, handleVector); | |
| float normalizedPositionOnHandle = projectionOnHandle / handleLengthSquared; | |
| float clampedPositionOnHandle = clamp( | |
| normalizedPositionOnHandle, | |
| 0.0, | |
| 1.0 | |
| ); | |
| float dist = length(pixelOffsetFromStart - handleVector * clampedPositionOnHandle); | |
| float projection = dot(pixelOffsetFromStart, normalize(handleVector)); | |
| float capsule = 1.0 - smoothstep( | |
| radius, | |
| radius + ANTI_ALIASING_DIST, | |
| dist | |
| ); | |
| capsule *= step(0.0, projection); | |
| return capsule; | |
| } | |
| half4 blackHandleColor(float2 p, float2 a, float2 b, float width) { | |
| float2 dir = normalize(b - a); | |
| float2 normal = float2(-dir.y, dir.x); | |
| float side = dot(p - a, normal) / width; | |
| float t = clamp(dot(p - a, dir) / length(b - a), 0.0, 1.0); | |
| half4 black = half4(0.06, 0.06, 0.07, 1.0); | |
| half4 gray = half4(0.34, 0.34, 0.37, 1.0); | |
| float highlight = smoothstep(-0.35, 0.75, side) * (1.0 - smoothstep(0.9, 1.0, t)); | |
| return mix(black, gray, half(highlight)); | |
| } | |
| half4 main(float2 fragCoord) { | |
| half4 original = composable.eval(fragCoord); | |
| half4 lensColor = mixGlass(fragCoord, original); | |
| float dist = length(fragCoord - center); | |
| float border = smoothstep(radius - LENS_BORDER_WIDTH, radius, dist) | |
| * (1.0 - smoothstep(radius, radius + ANTI_ALIASING_DIST, dist)); | |
| lensColor = mix(lensColor, half4(1.0), half(border)); | |
| float2 dir = normalize(float2(1.0, 1.0)); | |
| float2 handleStart = center + dir * radius; | |
| float2 handleEnd = handleStart + float2(radius * 0.5, radius * 0.5); | |
| float handle = handleMask( | |
| fragCoord, | |
| handleStart, | |
| handleEnd, | |
| 14.0 | |
| ); | |
| half4 handleColor = blackHandleColor(fragCoord, handleStart, handleEnd, 14.0); | |
| lensColor = mix(lensColor, handleColor, half(handle)); | |
| return lensColor; | |
| } | |
| """ | |
| @Composable | |
| fun LensEffectSample(modifier: Modifier) { | |
| Box( | |
| modifier = modifier.background(Color(0xFF2A2929)), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| DraggableLensText() | |
| } | |
| } | |
| @Composable | |
| private fun DraggableLensText() { | |
| val shader = remember { RuntimeShader(MAGNIFIER_SHADER) } | |
| var lensCenter by remember { mutableStateOf(Offset.Unspecified) } | |
| val density = LocalDensity.current | |
| val radiusPx = remember(density) { | |
| with(density) { 100.dp.toPx() } | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .pointerInput(Unit) { | |
| detectDragGestures( | |
| onDragStart = { offset -> | |
| lensCenter = offset | |
| }, | |
| onDrag = { change, _ -> | |
| lensCenter = change.position | |
| } | |
| ) | |
| } | |
| .graphicsLayer { | |
| compositingStrategy = CompositingStrategy.Offscreen | |
| val center = if (lensCenter == Offset.Unspecified) { | |
| Offset(size.width / 2, size.height / 2) | |
| } else lensCenter | |
| shader.setFloatUniform("center", center.x, center.y) | |
| shader.setFloatUniform("radius", radiusPx) | |
| renderEffect = RenderEffect | |
| .createRuntimeShaderEffect(shader, "composable") | |
| .asComposeRenderEffect() | |
| }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Text( | |
| text = "Magnify this text", | |
| color = Color.White, | |
| fontSize = 36.sp, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| } | |
| } | |
| @Preview | |
| @Composable | |
| fun LensEffectPreview() { | |
| LensEffectSample(modifier = Modifier.fillMaxSize()) | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Demo:
https://gist.github.com/user-attachments/assets/b642eaf3-0112-4f24-bdcc-400b5b23c8bc